Repository: Drakkar-Software/OctoBot-Tentacles Branch: master Commit: f0ebe0c14db0 Files: 1032 Total size: 4.8 MB Directory structure: gitextract_fehgh_us/ ├── .coveragerc ├── .github/ │ └── workflows/ │ └── main.yml ├── .gitignore ├── Automation/ │ ├── actions/ │ │ ├── cancel_open_order_action/ │ │ │ ├── __init__.py │ │ │ ├── cancel_open_orders.py │ │ │ └── metadata.json │ │ ├── sell_all_currencies_action/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── sell_all_currencies.py │ │ ├── send_notification_action/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── send_notification.py │ │ └── stop_trading_action/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── stop_trading.py │ ├── conditions/ │ │ ├── no_condition_condition/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── no_condition.py │ │ └── scripted_condition/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── scripted_condition.py │ └── trigger_events/ │ ├── period_check_event/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── period_check.py │ ├── price_threshold_event/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── price_threshold.py │ └── profitability_threshold_event/ │ ├── __init__.py │ ├── metadata.json │ └── profitability_threshold.py ├── Backtesting/ │ ├── collectors/ │ │ └── exchanges/ │ │ ├── exchange_bot_snapshot_data_collector/ │ │ │ ├── __init__.py │ │ │ ├── bot_snapshot_with_history_collector.py │ │ │ └── metadata.json │ │ ├── exchange_history_collector/ │ │ │ ├── __init__.py │ │ │ ├── history_collector.pxd │ │ │ ├── history_collector.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_history_collector.py │ │ └── exchange_live_collector/ │ │ ├── __init__.py │ │ ├── live_collector.pxd │ │ ├── live_collector.py │ │ └── metadata.json │ ├── converters/ │ │ └── exchanges/ │ │ └── legacy_data_converter/ │ │ ├── __init__.py │ │ ├── legacy_converter.pxd │ │ ├── legacy_converter.py │ │ └── metadata.json │ └── importers/ │ └── exchanges/ │ └── generic_exchange_importer/ │ ├── __init__.py │ ├── generic_exchange_importer.pxd │ ├── generic_exchange_importer.py │ └── metadata.json ├── Evaluator/ │ ├── RealTime/ │ │ └── instant_fluctuations_evaluator/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ ├── InstantFluctuationsEvaluator.json │ │ │ └── InstantMAEvaluator.json │ │ ├── instant_fluctuations.py │ │ ├── metadata.json │ │ └── resources/ │ │ ├── InstantFluctuationsEvaluator.md │ │ └── InstantMAEvaluator.md │ ├── Social/ │ │ ├── forum_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ └── RedditForumEvaluator.json │ │ │ ├── forum.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── RedditForumEvaluator.md │ │ ├── news_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ └── TwitterNewsEvaluator.json │ │ │ ├── metadata.json │ │ │ ├── news.py │ │ │ └── resources/ │ │ │ └── TwitterNewsEvaluator.md │ │ ├── signal_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ ├── TelegramChannelSignalEvaluator.json │ │ │ │ └── TelegramSignalEvaluator.json │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ ├── TelegramChannelSignalEvaluator.md │ │ │ │ └── TelegramSignalEvaluator.md │ │ │ ├── signal.py │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_telegram_channel_signal_evaluator.py │ │ └── trends_evaluator/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── GoogleTrendsEvaluator.json │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── GoogleTrendsEvaluator.md │ │ └── trends.py │ ├── Strategies/ │ │ ├── blank_strategy_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── blank_strategy.py │ │ │ ├── config/ │ │ │ │ └── BlankStrategyEvaluator.json │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── BlankStrategyEvaluator.md │ │ ├── dip_analyser_strategy_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ └── DipAnalyserStrategyEvaluator.json │ │ │ ├── dip_analyser_strategy.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── DipAnalyserStrategyEvaluator.md │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_dip_analyser_strategy_evaluator.py │ │ ├── mixed_strategies_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ ├── SimpleStrategyEvaluator.json │ │ │ │ └── TechnicalAnalysisStrategyEvaluator.json │ │ │ ├── metadata.json │ │ │ ├── mixed_strategies.py │ │ │ ├── resources/ │ │ │ │ ├── SimpleStrategyEvaluator.md │ │ │ │ └── TechnicalAnalysisStrategyEvaluator.md │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ ├── test_simple_strategy_evaluator.py │ │ │ └── test_technical_analysis_strategy_evaluator.py │ │ └── move_signals_strategy_evaluator/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── MoveSignalsStrategyEvaluator.json │ │ ├── metadata.json │ │ ├── move_signals_strategy.py │ │ ├── resources/ │ │ │ └── MoveSignalsStrategyEvaluator.md │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_move_signals_strategy_evaluator.py │ ├── TA/ │ │ ├── ai_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── ai.py │ │ │ ├── config/ │ │ │ │ └── GPTEvaluator.json │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── GPTEvaluator.md │ │ │ └── tests/ │ │ │ └── test_ai.py │ │ ├── momentum_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ ├── ADXMomentumEvaluator.json │ │ │ │ ├── BBMomentumEvaluator.json │ │ │ │ ├── EMAMomentumEvaluator.json │ │ │ │ ├── KlingerOscillatorMomentumEvaluator.json │ │ │ │ ├── KlingerOscillatorReversalConfirmationMomentumEvaluator.json │ │ │ │ ├── MACDMomentumEvaluator.json │ │ │ │ ├── RSIMomentumEvaluator.json │ │ │ │ └── RSIWeightMomentumEvaluator.json │ │ │ ├── metadata.json │ │ │ ├── momentum.py │ │ │ ├── resources/ │ │ │ │ ├── ADXMomentumEvaluator.md │ │ │ │ ├── BBMomentumEvaluator.md │ │ │ │ ├── EMAMomentumEvaluator.md │ │ │ │ ├── KlingerOscillatorMomentumEvaluator.md │ │ │ │ ├── KlingerOscillatorReversalConfirmationMomentumEvaluator.md │ │ │ │ ├── MACDMomentumEvaluator.md │ │ │ │ ├── RSIMomentumEvaluator.md │ │ │ │ └── RSIWeightMomentumEvaluator.md │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ ├── test_adx_momentum_evaluator.py │ │ │ ├── test_bollinger_bands_momentum_TA_evaluator.py │ │ │ ├── test_klinger_TA_evaluator.py │ │ │ ├── test_macd_TA_evaluator.py │ │ │ └── test_rsi_TA_evaluator.py │ │ ├── trend_evaluator/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ ├── DeathAndGoldenCrossEvaluator.json │ │ │ │ ├── DoubleMovingAverageTrendEvaluator.json │ │ │ │ ├── EMADivergenceTrendEvaluator.json │ │ │ │ └── SuperTrendEvaluator.json │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ ├── DeathAndGoldenCrossEvaluator.md │ │ │ │ ├── DoubleMovingAverageTrendEvaluator.md │ │ │ │ ├── EMADivergenceTrendEvaluator.md │ │ │ │ └── SuperTrendEvaluator.md │ │ │ ├── tests/ │ │ │ │ ├── __init__.py │ │ │ │ └── test_double_moving_averages_TA_evaluator.py │ │ │ └── trend.py │ │ └── volatility_evaluator/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── StochasticRSIVolatilityEvaluator.json │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── StochasticRSIVolatilityEvaluator.md │ │ └── volatility.py │ └── Util/ │ ├── candles_util/ │ │ ├── __init__.py │ │ ├── candles_util.pxd │ │ ├── candles_util.py │ │ ├── metadata.json │ │ └── tests/ │ │ └── test_candles_util.py │ ├── overall_state_analysis/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── overall_state_analysis.py │ ├── pattern_analysis/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── pattern_analysis.py │ ├── statistics_analysis/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── statistics_analysis.py │ ├── text_analysis/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── text_analysis.py │ └── trend_analysis/ │ ├── __init__.py │ ├── metadata.json │ └── trend_analysis.py ├── LICENSE ├── Meta/ │ ├── DSL_operators/ │ │ ├── exchange_operators/ │ │ │ ├── __init__.py │ │ │ ├── exchange_operator.py │ │ │ ├── exchange_private_data_operators/ │ │ │ │ ├── __init__.py │ │ │ │ └── portfolio_operators.py │ │ │ ├── exchange_public_data_operators/ │ │ │ │ ├── __init__.py │ │ │ │ └── ohlcv_operators.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ ├── exchange_public_data_operators/ │ │ │ │ └── test_ohlcv_operators.py │ │ │ └── test_mocks.py │ │ ├── python_std_operators/ │ │ │ ├── __init__.py │ │ │ ├── base_binary_operators.py │ │ │ ├── base_call_operators.py │ │ │ ├── base_compare_operators.py │ │ │ ├── base_expression_operators.py │ │ │ ├── base_iterable_operators.py │ │ │ ├── base_name_operators.py │ │ │ ├── base_nary_operators.py │ │ │ ├── base_subscripting_operators.py │ │ │ ├── base_unary_operators.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── test_base_operators.py │ │ │ └── test_dictionnaries.py │ │ └── ta_operators/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ ├── ta_operator.py │ │ ├── tests/ │ │ │ ├── test_docs_examples.py │ │ │ └── test_tulipy_technical_analysis_operators.py │ │ └── tulipy_technical_analysis_operators.py │ └── Keywords/ │ └── scripting_library/ │ ├── TA/ │ │ ├── __init__.py │ │ └── trigger/ │ │ ├── __init__.py │ │ └── eval_triggered.py │ ├── UI/ │ │ ├── __init__.py │ │ ├── inputs/ │ │ │ ├── __init__.py │ │ │ ├── library_user_inputs.py │ │ │ ├── select_candle.py │ │ │ ├── select_history.py │ │ │ ├── select_time_frame.py │ │ │ └── triggers.py │ │ └── plots/ │ │ ├── __init__.py │ │ └── displayed_elements.py │ ├── __init__.py │ ├── alerts/ │ │ ├── __init__.py │ │ └── notifications.py │ ├── backtesting/ │ │ ├── __init__.py │ │ ├── backtesting_data_collector.py │ │ ├── backtesting_data_selector.py │ │ ├── backtesting_intialization.py │ │ ├── backtesting_settings.py │ │ ├── default_backtesting_run_analysis_script.py │ │ ├── metadata.py │ │ └── run_data_analysis.py │ ├── configuration/ │ │ ├── __init__.py │ │ ├── exchanges_configuration.py │ │ ├── indexes_configuration.py │ │ ├── profile_data_configuration.py │ │ └── tentacles_configuration.py │ ├── constants.py │ ├── data/ │ │ ├── __init__.py │ │ ├── reading/ │ │ │ ├── __init__.py │ │ │ ├── exchange_private_data/ │ │ │ │ ├── __init__.py │ │ │ │ └── open_positions.py │ │ │ ├── exchange_public_data.py │ │ │ ├── metadata_reader.py │ │ │ └── trading_settings.py │ │ └── writing/ │ │ ├── __init__.py │ │ ├── plotting.py │ │ └── portfolio.py │ ├── errors.py │ ├── exchanges/ │ │ ├── __init__.py │ │ └── local_exchange.py │ ├── metadata.json │ ├── orders/ │ │ ├── __init__.py │ │ ├── cancelling.py │ │ ├── chaining.py │ │ ├── editing.py │ │ ├── grouping.py │ │ ├── mocks.py │ │ ├── open_orders.py │ │ ├── order_tags.py │ │ ├── order_types/ │ │ │ ├── __init__.py │ │ │ ├── create_order.py │ │ │ ├── limit_order.py │ │ │ ├── market_order.py │ │ │ ├── scaled_order.py │ │ │ ├── stop_loss_order.py │ │ │ ├── trailing_limit_order.py │ │ │ ├── trailing_market_order.py │ │ │ └── trailing_stop_loss_order.py │ │ ├── position_size/ │ │ │ ├── __init__.py │ │ │ ├── amount.py │ │ │ └── target_position.py │ │ └── waiting.py │ ├── settings/ │ │ ├── __init__.py │ │ └── script_settings.py │ └── tests/ │ ├── __init__.py │ ├── backtesting/ │ │ ├── __init__.py │ │ ├── data_store.py │ │ ├── test_backtesting_data_collector.py │ │ ├── test_collect_data_and_run_backtesting.py │ │ └── test_run_data.py │ ├── configuration/ │ │ ├── __init__.py │ │ ├── test_indexes_configuration.py │ │ └── test_profile_data_configuration.py │ ├── exchanges/ │ │ └── __init__.py │ ├── orders/ │ │ ├── __init__.py │ │ ├── order_types/ │ │ │ ├── __init__.py │ │ │ ├── test_create_order.py │ │ │ ├── test_limit_order.py │ │ │ ├── test_market_order.py │ │ │ ├── test_multiple_orders_creation.py │ │ │ ├── test_stop_loss_order.py │ │ │ ├── test_trailing_limit_order.py │ │ │ ├── test_trailing_market_order.py │ │ │ └── test_trailing_stop_loss_order.py │ │ ├── position_size/ │ │ │ ├── __init__.py │ │ │ └── test_target_position.py │ │ └── test_cancelling.py │ ├── static/ │ │ ├── config.json │ │ └── profile.json │ └── test_utils/ │ ├── __init__.py │ └── order_util.py ├── README.md ├── Services/ │ ├── Interfaces/ │ │ ├── telegram_bot_interface/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── telegram_bot.py │ │ │ └── tests/ │ │ │ └── test_bot_interface.py │ │ └── web_interface/ │ │ ├── __init__.py │ │ ├── advanced_controllers/ │ │ │ ├── __init__.py │ │ │ ├── configuration.py │ │ │ ├── home.py │ │ │ ├── matrix.py │ │ │ ├── strategy_optimizer.py │ │ │ └── tentacles_management.py │ │ ├── advanced_templates/ │ │ │ ├── advanced_evaluator_config.html │ │ │ ├── advanced_index.html │ │ │ ├── advanced_layout.html │ │ │ ├── advanced_matrix.html │ │ │ ├── advanced_strategy_optimizer.html │ │ │ ├── advanced_tentacle_packages.html │ │ │ └── advanced_tentacles.html │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── bots.py │ │ │ ├── config.py │ │ │ ├── dsl.py │ │ │ ├── exchanges.py │ │ │ ├── feedback.py │ │ │ ├── metadata.py │ │ │ ├── tentacles_packages.py │ │ │ ├── trading.py │ │ │ ├── user_commands.py │ │ │ └── webhook.py │ │ ├── constants.py │ │ ├── controllers/ │ │ │ ├── __init__.py │ │ │ ├── about.py │ │ │ ├── automation.py │ │ │ ├── backtesting.py │ │ │ ├── commands.py │ │ │ ├── community.py │ │ │ ├── community_authentication.py │ │ │ ├── configuration.py │ │ │ ├── dashboard.py │ │ │ ├── distributions/ │ │ │ │ ├── __init__.py │ │ │ │ └── market_making/ │ │ │ │ ├── __init__.py │ │ │ │ ├── cloud.py │ │ │ │ ├── configuration.py │ │ │ │ └── dashboard.py │ │ │ ├── dsl.py │ │ │ ├── errors.py │ │ │ ├── home.py │ │ │ ├── interface_settings.py │ │ │ ├── logs.py │ │ │ ├── medias.py │ │ │ ├── octobot_authentication.py │ │ │ ├── octobot_help.py │ │ │ ├── portfolio.py │ │ │ ├── profiles.py │ │ │ ├── reboot.py │ │ │ ├── robots.py │ │ │ ├── tentacles_config.py │ │ │ ├── terms.py │ │ │ ├── trading.py │ │ │ └── welcome.py │ │ ├── enums.py │ │ ├── errors.py │ │ ├── flask_util/ │ │ │ ├── __init__.py │ │ │ ├── browsing_data_provider.py │ │ │ ├── content_types_management.py │ │ │ ├── context_processor.py │ │ │ ├── cors.py │ │ │ ├── file_services.py │ │ │ ├── json_provider.py │ │ │ └── template_filters.py │ │ ├── login/ │ │ │ ├── __init__.py │ │ │ ├── open_source_package_required.py │ │ │ ├── user.py │ │ │ └── web_login_manager.py │ │ ├── metadata.json │ │ ├── models/ │ │ │ ├── __init__.py │ │ │ ├── backtesting.py │ │ │ ├── commands.py │ │ │ ├── community.py │ │ │ ├── configuration.py │ │ │ ├── dashboard.py │ │ │ ├── distributions/ │ │ │ │ ├── __init__.py │ │ │ │ └── market_making/ │ │ │ │ ├── __init__.py │ │ │ │ └── configuration.py │ │ │ ├── dsl.py │ │ │ ├── interface_settings.py │ │ │ ├── json_schemas.py │ │ │ ├── logs.py │ │ │ ├── medias.py │ │ │ ├── profiles.py │ │ │ ├── strategy_optimizer.py │ │ │ ├── tentacles.py │ │ │ ├── trading.py │ │ │ └── web_interface_tab.py │ │ ├── plugins/ │ │ │ ├── __init__.py │ │ │ ├── abstract_plugin.py │ │ │ └── plugin_management.py │ │ ├── security.py │ │ ├── static/ │ │ │ ├── css/ │ │ │ │ ├── bootstrap-editable.css │ │ │ │ ├── components/ │ │ │ │ │ └── configuration.css │ │ │ │ ├── layout.css │ │ │ │ ├── style.css │ │ │ │ └── w2ui_template.css │ │ │ ├── distributions/ │ │ │ │ └── market_making/ │ │ │ │ └── js/ │ │ │ │ ├── configuration.js │ │ │ │ └── dashboard.js │ │ │ ├── js/ │ │ │ │ ├── common/ │ │ │ │ │ ├── backtesting_util.js │ │ │ │ │ ├── bot_connection.js │ │ │ │ │ ├── candlesticks.js │ │ │ │ │ ├── common_handlers.js │ │ │ │ │ ├── cst.js │ │ │ │ │ ├── custom_elements.js │ │ │ │ │ ├── data_collector_util.js │ │ │ │ │ ├── dom_updater.js │ │ │ │ │ ├── exchange_accounts.js │ │ │ │ │ ├── feedback.js │ │ │ │ │ ├── json_editor_settings.js │ │ │ │ │ ├── on_load.js │ │ │ │ │ ├── pnl_history.js │ │ │ │ │ ├── portfolio_history.js │ │ │ │ │ ├── required.js │ │ │ │ │ ├── resources_rendering.js │ │ │ │ │ ├── stepper.js │ │ │ │ │ ├── tables_display.js │ │ │ │ │ ├── tracking.js │ │ │ │ │ ├── tutorial.js │ │ │ │ │ └── util.js │ │ │ │ └── components/ │ │ │ │ ├── advanced_matrix.js │ │ │ │ ├── automations.js │ │ │ │ ├── backtesting.js │ │ │ │ ├── commands.js │ │ │ │ ├── community.js │ │ │ │ ├── community_metrics.js │ │ │ │ ├── config_tentacle.js │ │ │ │ ├── configuration.js │ │ │ │ ├── dashboard.js │ │ │ │ ├── dashboard_tutorial_starter.js │ │ │ │ ├── data_collector.js │ │ │ │ ├── dsl_help.js │ │ │ │ ├── evaluator_configuration.js │ │ │ │ ├── extensions.js │ │ │ │ ├── logs.js │ │ │ │ ├── market_status.js │ │ │ │ ├── navbar.js │ │ │ │ ├── portfolio.js │ │ │ │ ├── profile_management.js │ │ │ │ ├── profiles_selector.js │ │ │ │ ├── strategy_optimizer.js │ │ │ │ ├── tentacles_configuration.js │ │ │ │ ├── trading.js │ │ │ │ ├── trading_type_selector.js │ │ │ │ ├── tradingview_email_config.js │ │ │ │ └── wait_reboot.js │ │ │ └── license.txt │ │ ├── templates/ │ │ │ ├── 404.html │ │ │ ├── 500.html │ │ │ ├── about.html │ │ │ ├── accounts.html │ │ │ ├── automations.html │ │ │ ├── backtesting.html │ │ │ ├── community.html │ │ │ ├── community_login.html │ │ │ ├── community_metrics.html │ │ │ ├── community_register.html │ │ │ ├── components/ │ │ │ │ ├── community/ │ │ │ │ │ ├── bot_selector.html │ │ │ │ │ ├── bots_stats.html │ │ │ │ │ ├── cloud_strategies.html │ │ │ │ │ ├── cloud_strategies_selector.html │ │ │ │ │ ├── login.html │ │ │ │ │ ├── octobot_cloud_description.html │ │ │ │ │ ├── octobot_cloud_features.html │ │ │ │ │ ├── tentacle_packages.html │ │ │ │ │ └── user_details.html │ │ │ │ ├── config/ │ │ │ │ │ ├── currency_card.html │ │ │ │ │ ├── editable_config.html │ │ │ │ │ ├── evaluator_card.html │ │ │ │ │ ├── exchange_card.html │ │ │ │ │ ├── notification_config.html │ │ │ │ │ ├── profiles.html │ │ │ │ │ ├── service_card.html │ │ │ │ │ ├── tentacle_card.html │ │ │ │ │ ├── tentacle_config_editor.html │ │ │ │ │ └── trader_card.html │ │ │ │ ├── modals/ │ │ │ │ │ ├── generic_modal.html │ │ │ │ │ └── trading_state_modal.html │ │ │ │ └── tentacles_packages/ │ │ │ │ └── tentacles_package_card.html │ │ │ ├── config_tentacle.html │ │ │ ├── data_collector.html │ │ │ ├── distributions/ │ │ │ │ ├── default/ │ │ │ │ │ ├── footer.html │ │ │ │ │ └── navbar.html │ │ │ │ └── market_making/ │ │ │ │ ├── cloud.html │ │ │ │ ├── cloud_features.html │ │ │ │ ├── configuration.html │ │ │ │ ├── dashboard.html │ │ │ │ ├── footer.html │ │ │ │ ├── interfaces.html │ │ │ │ ├── navbar.html │ │ │ │ └── portfolio.html │ │ │ ├── dsl_help.html │ │ │ ├── extensions.html │ │ │ ├── index.html │ │ │ ├── layout.html │ │ │ ├── login.html │ │ │ ├── logs.html │ │ │ ├── macros/ │ │ │ │ ├── backtesting_utils.html │ │ │ │ ├── cards.html │ │ │ │ ├── critical_notifications_alert.html │ │ │ │ ├── flash_messages.html │ │ │ │ ├── forms.html │ │ │ │ ├── major_issue_alert.html │ │ │ │ ├── starting_waiter.html │ │ │ │ ├── tables.html │ │ │ │ ├── tentacles.html │ │ │ │ ├── text.html │ │ │ │ └── trading_state.html │ │ │ ├── octobot_help.html │ │ │ ├── portfolio.html │ │ │ ├── profile.html │ │ │ ├── profiles_selector.html │ │ │ ├── robots.txt │ │ │ ├── symbol_market_status.html │ │ │ ├── terms.html │ │ │ ├── trading.html │ │ │ ├── trading_type_selector.html │ │ │ ├── tradingview_email_config.html │ │ │ ├── wait_reboot.html │ │ │ └── welcome.html │ │ ├── tests/ │ │ │ ├── __init__.py │ │ │ ├── distribution_tester.py │ │ │ ├── distributions/ │ │ │ │ ├── __init__.py │ │ │ │ ├── test_default.py │ │ │ │ └── test_market_making.py │ │ │ └── plugin_tester.py │ │ ├── util/ │ │ │ ├── __init__.py │ │ │ ├── browser_util.py │ │ │ └── flask_util.py │ │ ├── web.py │ │ └── websockets/ │ │ ├── __init__.py │ │ ├── abstract_websocket_namespace_notifier.py │ │ ├── backtesting.py │ │ ├── dashboard.py │ │ ├── data_collector.py │ │ ├── notifications.py │ │ └── strategy_optimizer.py │ ├── Notifiers/ │ │ ├── telegram_notifier/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── telegram.py │ │ ├── twitter_notifier/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── twitter.py │ │ └── web_notifier/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── web.py │ ├── Services_bases/ │ │ ├── google_service/ │ │ │ ├── __init__.py │ │ │ ├── google.py │ │ │ └── metadata.json │ │ ├── gpt_service/ │ │ │ ├── __init__.py │ │ │ ├── gpt.py │ │ │ └── metadata.json │ │ ├── reddit_service/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── reddit.py │ │ ├── telegram_api_service/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── telegram_api.py │ │ ├── telegram_service/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── telegram.py │ │ ├── trading_view_service/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── trading_view.py │ │ ├── twitter_service/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── twitter.py │ │ ├── web_service/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── web.py │ │ └── webhook_service/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── webhook.py │ └── Services_feeds/ │ ├── google_service_feed/ │ │ ├── __init__.py │ │ ├── google_feed.py │ │ └── metadata.json │ ├── reddit_service_feed/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── reddit_feed.py │ ├── telegram_api_service_feed/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── telegram_api_feed.py │ ├── telegram_service_feed/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── telegram_feed.py │ ├── trading_view_service_feed/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ └── trading_view_feed.py │ └── twitter_service_feed/ │ ├── __init__.py │ ├── metadata.json │ └── twitter_feed.py ├── Trading/ │ ├── Exchange/ │ │ ├── ascendex/ │ │ │ ├── __init__.py │ │ │ ├── ascendex_exchange.py │ │ │ └── metadata.json │ │ ├── ascendex_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── ascendex_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── binance/ │ │ │ ├── __init__.py │ │ │ ├── binance_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── binance.md │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_sandbox.py │ │ ├── binance_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── binance_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── binanceus/ │ │ │ ├── __init__.py │ │ │ ├── binanceus_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── BinanceUS.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── binanceus_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── binanceus_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── bingx/ │ │ │ ├── __init__.py │ │ │ ├── bingx_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bingx.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bingx_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── bingx_websocket.py │ │ │ └── metadata.json │ │ ├── bitfinex/ │ │ │ ├── __init__.py │ │ │ ├── bitfinex_exchange.py │ │ │ └── metadata.json │ │ ├── bitfinex_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── bitfinex_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── bitget/ │ │ │ ├── __init__.py │ │ │ ├── bitget_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bitget.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bitget_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── bitget_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bithumb/ │ │ │ ├── __init__.py │ │ │ ├── bithumb_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bithumb.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bitmart/ │ │ │ ├── __init__.py │ │ │ ├── bitmart_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── bitmart.md │ │ ├── bitmart_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── bitmart_websocket.py │ │ │ └── metadata.json │ │ ├── bitmex/ │ │ │ ├── __init__.py │ │ │ ├── bitmex_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bitmex.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bitso/ │ │ │ ├── __init__.py │ │ │ ├── bitso_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bitso.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bitstamp/ │ │ │ ├── __init__.py │ │ │ ├── bitstamp_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bitstamp.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bybit/ │ │ │ ├── __init__.py │ │ │ ├── bybit_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── bybit.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── bybit_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── bybit_websocket.py │ │ │ └── metadata.json │ │ ├── coinbase/ │ │ │ ├── __init__.py │ │ │ ├── coinbase_exchange.py │ │ │ └── metadata.json │ │ ├── coinbase_pro/ │ │ │ ├── __init__.py │ │ │ ├── coinbase_pro_exchange.py │ │ │ └── metadata.json │ │ ├── coinbase_pro_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── coinbase_pro_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── coinbase_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── coinbase_websocket.py │ │ │ └── metadata.json │ │ ├── coinex/ │ │ │ ├── __init__.py │ │ │ ├── coinex_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── coinex.md │ │ ├── coinex_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── coinex_websocket.py │ │ │ └── metadata.json │ │ ├── configurable_default_ccxt_rest/ │ │ │ ├── __init__.py │ │ │ ├── configurable_default_rest_ccxt_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── configurable_default_rest_ccxt_exchange.md │ │ ├── cryptocom/ │ │ │ ├── __init__.py │ │ │ ├── cryptocom_exchange.py │ │ │ └── metadata.json │ │ ├── cryptocom_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── cryptocom_websocket.py │ │ │ └── metadata.json │ │ ├── gateio/ │ │ │ ├── __init__.py │ │ │ ├── gateio_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── gateio.md │ │ ├── gateio_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── gateio_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── hitbtc/ │ │ │ ├── __init__.py │ │ │ ├── hitbtc_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── hitbtc.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── hollaex/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ └── hollaex.json │ │ │ ├── hollaex_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── hollaex.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── hollaex_autofilled/ │ │ │ ├── __init__.py │ │ │ ├── config/ │ │ │ │ └── HollaexAutofilled.json │ │ │ ├── hollaex_autofilled_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── hollaex_autofilled.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── hollaex_autofilled_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── hollaex_autofilled_websocket.py │ │ │ └── metadata.json │ │ ├── hollaex_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── hollaex_websocket.py │ │ │ └── metadata.json │ │ ├── htx/ │ │ │ ├── __init__.py │ │ │ ├── htx_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── htx.md │ │ ├── htx_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── htx_websocket.py │ │ │ └── metadata.json │ │ ├── huobi/ │ │ │ ├── __init__.py │ │ │ ├── huobi_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── huobi.md │ │ ├── huobi_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── huobi_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── hyperliquid/ │ │ │ ├── __init__.py │ │ │ ├── hyperliquid_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── hyperliquid.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── hyperliquid_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── hyperliquid_websocket.py │ │ │ └── metadata.json │ │ ├── kraken/ │ │ │ ├── __init__.py │ │ │ ├── kraken_exchange.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── kraken.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── kraken_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── kraken_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── kucoin/ │ │ │ ├── __init__.py │ │ │ ├── kucoin_exchange.py │ │ │ └── metadata.json │ │ ├── kucoin_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── kucoin_websocket.py │ │ │ ├── metadata.json │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── lbank/ │ │ │ ├── __init__.py │ │ │ ├── lbank_exchange.py │ │ │ ├── metadata.json │ │ │ └── resources/ │ │ │ └── lbank.md │ │ ├── lbank_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── lbank_websocket.py │ │ │ └── metadata.json │ │ ├── mexc/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── mexc_exchange.py │ │ ├── mexc_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── mexc_websocket.py │ │ ├── myokx/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── myokx_exchange.py │ │ │ └── resources/ │ │ │ └── myokx.md │ │ ├── myokx_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── myokx_websocket.py │ │ ├── ndax/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── ndax_exchange.py │ │ │ ├── resources/ │ │ │ │ └── ndax.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── okcoin/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── okcoin_exchange.py │ │ │ ├── resources/ │ │ │ │ └── okcoin.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── okx/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── okx_exchange.py │ │ │ ├── resources/ │ │ │ │ └── okx.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── okx_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── okx_websocket.py │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ └── test_unauthenticated_mocked_feeds.py │ │ ├── okxus/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── okxus_exchange.py │ │ │ └── resources/ │ │ │ └── okxus.md │ │ ├── okxus_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ └── okxus_websocket.py │ │ ├── phemex/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── phemex_exchange.py │ │ │ ├── resources/ │ │ │ │ └── phemex.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── phemex_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── phemex_websocket.py │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── poloniex/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── poloniex_exchange.py │ │ │ ├── resources/ │ │ │ │ └── poloniex.md │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── polymarket/ │ │ │ ├── __init__.py │ │ │ ├── ccxt/ │ │ │ │ ├── __init__.py │ │ │ │ ├── polymarket_abstract.py │ │ │ │ ├── polymarket_async.py │ │ │ │ ├── polymarket_pro.py │ │ │ │ └── polymarket_sync.py │ │ │ ├── metadata.json │ │ │ ├── polymarket_exchange.py │ │ │ ├── resources/ │ │ │ │ └── Polymarket.md │ │ │ ├── script/ │ │ │ │ ├── __init__.py │ │ │ │ └── download.py │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── polymarket_websocket_feed/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── polymarket_websocket.py │ │ │ └── tests/ │ │ │ └── __init__.py │ │ ├── upbitexchange/ │ │ │ ├── __init__.py │ │ │ ├── metadata.json │ │ │ ├── resources/ │ │ │ │ └── upbitexchange.md │ │ │ ├── tests/ │ │ │ │ └── __init__.py │ │ │ └── upbit_exchange.py │ │ └── wavesexchange/ │ │ ├── __init__.py │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── wavesexchange.md │ │ ├── tests/ │ │ │ └── __init__.py │ │ └── wavesexchange_exchange.py │ └── Mode/ │ ├── arbitrage_trading_mode/ │ │ ├── __init__.py │ │ ├── arbitrage_container.py │ │ ├── arbitrage_trading.py │ │ ├── config/ │ │ │ └── ArbitrageTradingMode.json │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── ArbitrageTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_arbitrage_container.py │ │ ├── test_arbitrage_trading_mode_consumer.py │ │ └── test_arbitrage_trading_mode_producer.py │ ├── blank_trading_mode/ │ │ ├── __init__.py │ │ ├── blank_trading.py │ │ ├── config/ │ │ │ └── BlankTradingMode.json │ │ ├── metadata.json │ │ └── resources/ │ │ └── BlankTradingMode.md │ ├── daily_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── DailyTradingMode.json │ │ ├── daily_trading.pxd │ │ ├── daily_trading.py │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── DailyTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_daily_trading_mode.py │ │ ├── test_daily_trading_mode_consumer.py │ │ └── test_daily_trading_mode_producer.py │ ├── dca_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── DCATradingMode.json │ │ ├── dca_trading.py │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── DCATradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_dca_trading_mode.py │ ├── dip_analyser_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── DipAnalyserTradingMode.json │ │ ├── dip_analyser_trading.py │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── DipAnalyserTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_dip_analyser_trading_mode.py │ ├── grid_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── GridTradingMode.json │ │ ├── grid_trading.py │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── GridTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ ├── open_orders_data.py │ │ └── test_grid_trading_mode.py │ ├── index_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── IndexTradingMode.json │ │ ├── index_distribution.py │ │ ├── index_trading.py │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── IndexTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_index_distribution.py │ │ └── test_index_trading_mode.py │ ├── market_making_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── MarketMakingTradingMode.json │ │ ├── market_making_trading.py │ │ ├── metadata.json │ │ ├── order_book_distribution.py │ │ ├── reference_price.py │ │ ├── resources/ │ │ │ └── MarketMakingTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_market_making_trading.py │ │ └── test_order_book_distribution.py │ ├── remote_trading_signals_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── RemoteTradingSignalsTradingMode.json │ │ ├── metadata.json │ │ ├── remote_trading_signals_trading.py │ │ ├── resources/ │ │ │ └── RemoteTradingSignalsTradingMode.md │ │ └── tests/ │ │ ├── __init__.py │ │ ├── test_remote_trading_signals_trading_consumer.py │ │ └── test_remote_trading_signals_trading_producer.py │ ├── signal_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── SignalTradingMode.json │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── SignalTradingMode.md │ │ └── signal_trading.py │ ├── staggered_orders_trading_mode/ │ │ ├── __init__.py │ │ ├── config/ │ │ │ └── StaggeredOrdersTradingMode.json │ │ ├── metadata.json │ │ ├── resources/ │ │ │ └── StaggeredOrdersTradingMode.md │ │ ├── staggered_orders_trading.py │ │ └── tests/ │ │ ├── __init__.py │ │ └── test_staggered_orders_trading_mode.py │ └── trading_view_signals_trading_mode/ │ ├── __init__.py │ ├── config/ │ │ └── TradingViewSignalsTradingMode.json │ ├── metadata.json │ ├── resources/ │ │ └── TradingViewSignalsTradingMode.md │ ├── tests/ │ │ ├── __init__.py │ │ └── test_trading_view_signals_trading.py │ └── trading_view_signals_trading.py ├── metadata.yaml ├── octobot_config.json ├── profiles/ │ ├── arbitrage_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── ArbitrageTradingMode.json │ │ └── tentacles_config.json │ ├── copy_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── RemoteTradingSignalsTradingMode.json │ │ └── tentacles_config.json │ ├── daily_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ ├── DailyTradingMode.json │ │ │ └── SimpleStrategyEvaluator.json │ │ └── tentacles_config.json │ ├── dip_analyser/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ ├── DipAnalyserStrategyEvaluator.json │ │ │ ├── DipAnalyserTradingMode.json │ │ │ ├── InstantFluctuationsEvaluator.json │ │ │ └── RSIWeightMomentumEvaluator.json │ │ └── tentacles_config.json │ ├── gpt_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ ├── DCATradingMode.json │ │ │ ├── GPTEvaluator.json │ │ │ └── SimpleStrategyEvaluator.json │ │ └── tentacles_config.json │ ├── grid_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── GridTradingMode.json │ │ └── tentacles_config.json │ ├── index_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── IndexTradingMode.json │ │ └── tentacles_config.json │ ├── market_making/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── MarketMakingTradingMode.json │ │ └── tentacles_config.json │ ├── non-trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── BlankStrategyEvaluator.json │ │ └── tentacles_config.json │ ├── signal_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ ├── MoveSignalsStrategyEvaluator.json │ │ │ └── SignalTradingMode.json │ │ └── tentacles_config.json │ ├── simple_dca/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── DCATradingMode.json │ │ └── tentacles_config.json │ ├── smart_dca/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ ├── DCATradingMode.json │ │ │ ├── EMAMomentumEvaluator.json │ │ │ └── SimpleStrategyEvaluator.json │ │ └── tentacles_config.json │ ├── staggered_orders_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── StaggeredOrdersTradingMode.json │ │ └── tentacles_config.json │ ├── tradingview_trading/ │ │ ├── profile.json │ │ ├── specific_config/ │ │ │ └── TradingViewSignalsTradingMode.json │ │ └── tentacles_config.json │ └── trailing_grid_trading/ │ ├── profile.json │ ├── specific_config/ │ │ └── GridTradingMode.json │ └── tentacles_config.json └── scripts/ └── clear_cloudflare_cache.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] include = tentacles/ ================================================ FILE: .github/workflows/main.yml ================================================ name: OctoBot-Tentacles-CI on: push: branches: - 'master' - 'dev' - 'beta' tags: - '*' pull_request: jobs: tests: name: ${{ matrix.os }}${{ matrix.arch }} - Python - ${{ matrix.python }} - Tests runs-on: ${{ matrix.os }} strategy: matrix: os: [windows-latest, ubuntu-latest ] arch: [ x64 ] python: [ '3.10' ] steps: - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} architecture: ${{ matrix.arch }} - name: Install OctoBot on Unix if: matrix.os != 'windows-latest' env: OCTOBOT_GH_REPO: https://github.com/Drakkar-Software/OctoBot.git OCTOBOT_DEFAULT_BRANCH: dev run: | echo "GITHUB_REF=$GITHUB_REF" TARGET_BRANCH=$([ "$GITHUB_HEAD_REF" == "" ] && echo ${GITHUB_REF##*/} || echo "$GITHUB_HEAD_REF") git clone -q $OCTOBOT_GH_REPO -b ${TARGET_BRANCH} || git clone -q $OCTOBOT_GH_REPO -b $OCTOBOT_DEFAULT_BRANCH cd OctoBot git status pip install --prefer-binary -r dev_requirements.txt -r requirements.txt -r full_requirements.txt cd .. mkdir new_tentacles cp -r Automation Backtesting Evaluator Meta Services Trading profiles new_tentacles cd OctoBot python start.py tentacles -d "../new_tentacles" -p "../../any_platform.zip" python start.py tentacles --install --location "../any_platform.zip" --all - name: Install OctoBot on Windows if: matrix.os == 'windows-latest' env: OCTOBOT_GH_REPO: https://github.com/Drakkar-Software/OctoBot.git OCTOBOT_DEFAULT_BRANCH: dev run: | echo "GITHUB_REF=$env:GITHUB_REF" $env:TARGET_BRANCH = $env:GITHUB_REF If ((Test-Path env:GITHUB_HEAD_REF) -and -not ([string]::IsNullOrWhiteSpace($env:GITHUB_HEAD_REF))) { echo "using GITHUB_HEAD_REF" $env:TARGET_BRANCH = $env:GITHUB_HEAD_REF } echo "TARGET_BRANCH=$env:TARGET_BRANCH" If ($env:TARGET_BRANCH -notcontains "refs/tags/") { $env:TENTACLES_URL_TAG = "latest" } echo "cleaned TARGET_BRANCH=$env:TARGET_BRANCH" git clone -q $env:OCTOBOT_GH_REPO -b $env:TARGET_BRANCH.Replace('refs/heads/','') if ($LastExitCode -ne 0) { git clone -q $env:OCTOBOT_GH_REPO -b $env:OCTOBOT_DEFAULT_BRANCH } cd OctoBot git status pip install --upgrade pip setuptools wheel pip install --prefer-binary -r dev_requirements.txt -r requirements.txt -r full_requirements.txt cd .. mkdir new_tentacles xcopy Automation new_tentacles\\Automation /E/H/I xcopy Backtesting new_tentacles\\Backtesting /E/H/I xcopy Evaluator new_tentacles\\Evaluator /E/H/I xcopy Meta new_tentacles\\Meta /E/H/I xcopy Services new_tentacles\\Services /E/H/I xcopy Trading new_tentacles\\Trading /E/H/I xcopy profiles new_tentacles\\profiles /E/H/I cd OctoBot python start.py tentacles -d "../new_tentacles" -p "../../any_platform.zip" python start.py tentacles --install --location "../any_platform.zip" --all shell: powershell - name: Pytests run: | cd OctoBot pytest --cov=. --cov-config=.coveragerc --durations=0 -rw --ignore=tentacles/Trading/Exchange tentacles - name: Publish coverage if: github.event_name == 'push' continue-on-error: true run: coveralls env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} upload_tentacles: needs: tests name: ${{ matrix.os }}${{ matrix.arch }} - Python - ${{ matrix.python }} - Upload runs-on: ${{ matrix.os }} strategy: matrix: os: [ ubuntu-latest ] arch: [ x64 ] python: [ '3.10' ] steps: - uses: actions/checkout@v5 - name: Set Environment Variables run: | echo "S3_API_KEY=${{ secrets.S3_API_KEY }}" >> $GITHUB_ENV echo "S3_API_SECRET_KEY=${{ secrets.S3_API_SECRET_KEY }}" >> $GITHUB_ENV echo "S3_REGION_NAME=${{ secrets.S3_REGION_NAME }}" >> $GITHUB_ENV echo "S3_ENDPOINT_URL=${{ secrets.S3_ENDPOINT_URL }}" >> $GITHUB_ENV echo "CLOUDFLARE_TOKEN=${{ secrets.CLOUDFLARE_TOKEN }}" >> $GITHUB_ENV echo "CLOUDFLARE_ZONE=${{ secrets.CLOUDFLARE_ZONE }}" >> $GITHUB_ENV TARGET_BRANCH=$([ "$GITHUB_HEAD_REF" == "" ] && echo ${GITHUB_REF##*/} || echo "$GITHUB_HEAD_REF") echo "TARGET_BRANCH=${TARGET_BRANCH}" >> $GITHUB_ENV - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} architecture: ${{ matrix.arch }} - name: Produce tentacles package env: OCTOBOT_GH_REPO: https://github.com/Drakkar-Software/OctoBot.git OCTOBOT_DEFAULT_BRANCH: dev run: | git clone -q $OCTOBOT_GH_REPO -b ${TARGET_BRANCH} || git clone -q $OCTOBOT_GH_REPO -b $OCTOBOT_DEFAULT_BRANCH cd OctoBot git status pip install --prefer-binary -r dev_requirements.txt -r requirements.txt -r full_requirements.txt cd .. mkdir new_tentacles cp -r Automation Backtesting Evaluator Meta Services Trading profiles new_tentacles - name: Publish tag tentacles if: startsWith(github.ref, 'refs/tags') env: S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} run: | sed -i "s/VERSION_PLACEHOLDER/${TARGET_BRANCH#refs/*/}/g" metadata.yaml cd OctoBot python start.py tentacles -m "../metadata.yaml" -d "../new_tentacles" -p "../../any_platform.zip" -ite -ute ${{ secrets.TENTACLES_OFFICIAL_PATH }}/tentacles -upe ${{ secrets.TENTACLES_OFFICIAL_PATH }}/packages/full/${{ secrets.TENTACLES_REPOSITORY_NAME }}/ python ../scripts/clear_cloudflare_cache.py ${TARGET_BRANCH#refs/*/} - name: Publish latest tentacles if: github.ref == 'refs/heads/dev' && startsWith(github.ref, 'refs/tags') != true env: S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} run: | sed -i "s/VERSION_PLACEHOLDER/latest/g" metadata.yaml cd OctoBot python start.py tentacles -m "../metadata.yaml" -d "../new_tentacles" -p "../../any_platform.zip" -upe ${{ secrets.TENTACLES_OFFICIAL_PATH }}/packages/full/${{ secrets.TENTACLES_REPOSITORY_NAME }}/ python ../scripts/clear_cloudflare_cache.py latest - name: Publish stable tentacles if: github.ref == 'refs/heads/master' env: S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} run: | sed -i "s/VERSION_PLACEHOLDER/stable/g" metadata.yaml cd OctoBot python start.py tentacles -m "../metadata.yaml" -d "../new_tentacles" -p "../../any_platform.zip" -upe ${{ secrets.TENTACLES_OFFICIAL_PATH }}/packages/full/${{ secrets.TENTACLES_REPOSITORY_NAME }}/ python ../scripts/clear_cloudflare_cache.py stable - name: Publish cleaned branch tentacles if: startsWith(github.ref, 'refs/tags') != true && github.ref != 'refs/heads/master' env: S3_BUCKET_NAME: ${{ secrets.S3_DEV_BUCKET_NAME }} run: | branch="${TARGET_BRANCH##*/}" sed -i "s/VERSION_PLACEHOLDER/$branch/g" metadata.yaml sed -i "s/base/$branch/g" metadata.yaml sed -i "s/officials/dev/g" metadata.yaml cd OctoBot python start.py tentacles -m "../metadata.yaml" -d "../new_tentacles" -p "../../any_platform.zip" -upe ${{ secrets.TENTACLES_OFFICIAL_PATH }}/packages/full/${{ secrets.TENTACLES_REPOSITORY_NAME }}/ python ../scripts/clear_cloudflare_cache.py $branch notify: if: ${{ failure() }} needs: - tests - upload_tentacles uses: Drakkar-Software/.github/.github/workflows/failure_notify_workflow.yml@master secrets: DISCORD_GITHUB_WEBHOOK: ${{ secrets.DISCORD_GITHUB_WEBHOOK }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ \.idea/ /__init__.py Backtesting/__init__.py Backtesting/collectors/__init__.py Backtesting/collectors/exchanges/__init__.py Backtesting/converters/__init__.py Backtesting/converters/exchanges/__init__.py Backtesting/importers/__init__.py Backtesting/importers/exchanges/__init__.py Evaluator/__init__.py Evaluator/RealTime/__init__.py Evaluator/Social/__init__.py Evaluator/Strategies/__init__.py Evaluator/TA/__init__.py Evaluator/Util/__init__.py profiles/__init__.py Services/__init__.py Services/Interfaces/__init__.py Services/Notifiers/__init__.py Services/Services_bases/__init__.py Services/Services_feeds/__init__.py Trading/__init__.py Trading/Exchange/__init__.py Trading/Mode/__init__.py ================================================ FILE: Automation/actions/cancel_open_order_action/__init__.py ================================================ from .cancel_open_orders import CancelOpenOrders ================================================ FILE: Automation/actions/cancel_open_order_action/cancel_open_orders.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import asyncio import octobot_commons.configuration as configuration import octobot_trading.api as trading_api import octobot.automation.bases.abstract_action as abstract_action class CancelOpenOrders(abstract_action.AbstractAction): async def process(self): exchange_managers = trading_api.get_exchange_managers_from_exchange_ids(trading_api.get_exchange_ids()) await asyncio.gather(*( trading_api.cancel_all_open_orders(exchange_manager) for exchange_manager in exchange_managers )) @staticmethod def get_description() -> str: return "Cancel all OctoBot-managed open orders on each exchange." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return {} def apply_config(self, config): # no config pass ================================================ FILE: Automation/actions/cancel_open_order_action/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CancelOpenOrders"], "tentacles-requirements": [] } ================================================ FILE: Automation/actions/sell_all_currencies_action/__init__.py ================================================ from .sell_all_currencies import SellAllCurrencies ================================================ FILE: Automation/actions/sell_all_currencies_action/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["SellAllCurrencies"], "tentacles-requirements": [] } ================================================ FILE: Automation/actions/sell_all_currencies_action/sell_all_currencies.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import asyncio import octobot_commons.configuration as configuration import octobot_trading.api as trading_api import octobot.automation.bases.abstract_action as abstract_action class SellAllCurrencies(abstract_action.AbstractAction): async def process(self): exchange_managers = trading_api.get_exchange_managers_from_exchange_ids(trading_api.get_exchange_ids()) await asyncio.gather(*( trading_api.sell_all_everything_for_reference_market(exchange_manager) for exchange_manager in exchange_managers )) @staticmethod def get_description() -> str: return "Market sell each currency for the reference market on each exchange." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return {} def apply_config(self, config): # no config pass ================================================ FILE: Automation/actions/send_notification_action/__init__.py ================================================ from .send_notification import SendNotification ================================================ FILE: Automation/actions/send_notification_action/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["SendNotification"], "tentacles-requirements": [] } ================================================ FILE: Automation/actions/send_notification_action/send_notification.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import octobot_commons.enums as commons_enums import octobot_commons.configuration as configuration import octobot_services.enums as services_enums import octobot_services.api as services_api import octobot.automation.bases.abstract_action as abstract_action class SendNotification(abstract_action.AbstractAction): MESSAGE = "message" def __init__(self): super().__init__() self.notification_message = None async def process(self): await services_api.send_notification( services_api.create_notification( self.notification_message, category=services_enums.NotificationCategory.OTHER ) ) @staticmethod def get_description() -> str: return f"Sends the configured message. " \ f"Configure notification channels in the 'Accounts' tab. " \ f"The notification type is '{services_enums.NotificationCategory.OTHER.value.capitalize()}'." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return { self.MESSAGE: UI.user_input( self.MESSAGE, commons_enums.UserInputTypes.TEXT, "Your notification triggered", inputs, title="Message to include in your notification.", parent_input_name=step_name, ) } def apply_config(self, config): self.notification_message = config[self.MESSAGE] ================================================ FILE: Automation/actions/stop_trading_action/__init__.py ================================================ from .stop_trading import StopTrading ================================================ FILE: Automation/actions/stop_trading_action/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["StopTrading"], "tentacles-requirements": [] } ================================================ FILE: Automation/actions/stop_trading_action/stop_trading.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import octobot_commons.constants as commons_constants import octobot_services.interfaces.util as interfaces_util import tentacles.Automation.actions.cancel_open_order_action as cancel_open_orders class StopTrading(cancel_open_orders.CancelOpenOrders): PROFILE_ID = commons_constants.DEFAULT_PROFILE # non trading profile async def process(self): # cancel all open orders await super().process() # select non trading profile config = interfaces_util.get_edited_config(dict_only=False) config.select_profile(self.PROFILE_ID) config.save() # reboot interfaces_util.get_bot_api().restart_bot() @staticmethod def get_description() -> str: return "Cancel all OctoBot-managed open orders on each exchange, switch to the Non-Trading profile " \ "and restart OctoBot." ================================================ FILE: Automation/conditions/no_condition_condition/__init__.py ================================================ from .no_condition import NoCondition ================================================ FILE: Automation/conditions/no_condition_condition/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["NoCondition"], "tentacles-requirements": [] } ================================================ FILE: Automation/conditions/no_condition_condition/no_condition.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import octobot_commons.configuration as configuration import octobot.automation.bases.abstract_condition as abstract_condition class NoCondition(abstract_condition.AbstractCondition): async def evaluate(self) -> bool: return True @staticmethod def get_description() -> str: return "Is always passing." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return {} def apply_config(self, config): # no config pass ================================================ FILE: Automation/conditions/scripted_condition/__init__.py ================================================ from .scripted_condition import ScriptedCondition ================================================ FILE: Automation/conditions/scripted_condition/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ScriptedCondition"], "tentacles-requirements": [] } ================================================ FILE: Automation/conditions/scripted_condition/scripted_condition.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import typing import octobot_commons.configuration as configuration import octobot_trading.api as trading_api import octobot_commons.enums as commons_enums import octobot.automation.bases.abstract_condition as abstract_condition import octobot_commons.dsl_interpreter as dsl_interpreter import tentacles.Meta.DSL_operators as dsl_operators class ScriptedCondition(abstract_condition.AbstractCondition): SCRIPT = "script" EXCHANGE = "exchange" def __init__(self): super().__init__() self.script: str = "" self.exchange_name: str = "" self._dsl_interpreter: typing.Optional[dsl_interpreter.Interpreter] = None async def evaluate(self) -> bool: if self._dsl_interpreter: script_result = await self._dsl_interpreter.interprete(self.script) return bool(script_result) raise ValueError("Scripted condition is not properly configured, the script is likely invalid.") @staticmethod def get_description() -> str: return "Evaluates a scripted condition using the OctoBot DSL." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: exchanges = list(trading_api.get_exchange_names()) return { self.SCRIPT: UI.user_input( self.SCRIPT, commons_enums.UserInputTypes.TEXT, "", inputs, title="Scripted condition: the OctoBot DSL expression to evaluate (more info in automation details). Its return value will be converted to a boolean using \"bool()\" to determine if the condition is met.", parent_input_name=step_name, ), self.EXCHANGE: UI.user_input( self.EXCHANGE, commons_enums.UserInputTypes.OPTIONS, exchanges[0], inputs, options=exchanges, title="Exchange: the name of the exchange to use for the condition.", parent_input_name=step_name, ) } def apply_config(self, config): self.script = config[self.SCRIPT] self.exchange_name = config[self.EXCHANGE] if self.script and self.exchange_name: self._dsl_interpreter = self._create_dsl_interpreter() self._validate_script() else: self._dsl_interpreter = None def _validate_script(self): try: self._dsl_interpreter.prepare(self.script) self.logger.info( f"Formula interpreter successfully prepared \"{self.script}\" condition" ) except Exception as e: self.logger.error(f"Error when parsing condition {self.script}: {e}") raise e def _create_dsl_interpreter(self): exchange_manager = self._get_exchange_manager() ohlcv_operators = [] portfolio_operators = [] if exchange_manager is not None: ohlcv_operators = dsl_operators.exchange_operators.create_ohlcv_operators( exchange_manager, None, None ) portfolio_operators = dsl_operators.exchange_operators.create_portfolio_operators( exchange_manager ) return dsl_interpreter.Interpreter( dsl_interpreter.get_all_operators() + ohlcv_operators + portfolio_operators ) def _get_exchange_manager(self): for exchange_id in trading_api.get_exchange_ids(): exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) if exchange_manager.exchange_name == self.exchange_name and exchange_manager.is_backtesting == False: return exchange_manager raise ValueError(f"No exchange manager found for exchange name: {self.exchange_name}") ================================================ FILE: Automation/trigger_events/period_check_event/__init__.py ================================================ from .period_check import PeriodicCheck ================================================ FILE: Automation/trigger_events/period_check_event/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["PeriodicCheck"], "tentacles-requirements": [] } ================================================ FILE: Automation/trigger_events/period_check_event/period_check.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import asyncio import octobot_commons.enums as commons_enums import octobot_commons.configuration as configuration import octobot.automation.bases.abstract_trigger_event as abstract_trigger_event class PeriodicCheck(abstract_trigger_event.AbstractTriggerEvent): UPDATE_PERIOD = "update_period" def __init__(self): super().__init__() self.waiter_task = None self.waiting_time = None async def stop(self): await super().stop() if self.waiter_task is not None and not self.waiter_task.done(): self.waiter_task.cancel() async def _get_next_event(self): if self.should_stop: raise StopIteration self.waiter_task = asyncio.create_task(asyncio.sleep(self.waiting_time)) await self.waiter_task @staticmethod def get_description() -> str: return "Will trigger periodically, at the specified update period." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return { self.UPDATE_PERIOD: UI.user_input( self.UPDATE_PERIOD, commons_enums.UserInputTypes.FLOAT, 300, inputs, title="Update period: number of seconds to wait between each update.", parent_input_name=step_name, ) } def apply_config(self, config): self.waiting_time = config[self.UPDATE_PERIOD] ================================================ FILE: Automation/trigger_events/price_threshold_event/__init__.py ================================================ from .price_threshold import PriceThreshold ================================================ FILE: Automation/trigger_events/price_threshold_event/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["PriceThreshold"], "tentacles-requirements": [] } ================================================ FILE: Automation/trigger_events/price_threshold_event/price_threshold.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import asyncio import decimal import async_channel.enums as channel_enums import octobot_commons.enums as commons_enums import octobot_commons.configuration as configuration import octobot_commons.channels_name as channels_name import octobot.automation.bases.abstract_trigger_event as abstract_trigger_event import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.api as trading_api class PriceThreshold(abstract_trigger_event.AbstractTriggerEvent): TARGET_PRICE = "target_price" SYMBOL = "symbol" TRIGGER_ONLY_ONCE = "trigger_only_once" MAX_TRIGGER_FREQUENCY = "max_trigger_frequency" def __init__(self): super().__init__() self.waiter_task = None self.symbol = None self.target_price = None self.last_price = None self.trigger_event = asyncio.Event() self.registered_consumer = False self.consumers = [] async def _register_consumer(self): self.registered_consumer = True for exchange_id in trading_api.get_exchange_ids(): self.consumers.append( await exchanges_channel.get_chan( channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value, exchange_id ).new_consumer( self.mark_price_callback, priority_level=channel_enums.ChannelConsumerPriorityLevels.MEDIUM.value, symbol=self.symbol ) ) async def mark_price_callback( self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, mark_price ): if self.should_stop: # do not go any further if the action has been stopped return self._check_threshold(mark_price) self._update_last_price(mark_price) def _update_last_price(self, mark_price): self.last_price = mark_price def _check_threshold(self, mark_price): if self.last_price is None: return if mark_price >= self.target_price > self.last_price or mark_price <= self.target_price < self.last_price: # mark_price crossed self.target_price threshold self.trigger_event.set() async def stop(self): await super().stop() if self.waiter_task is not None and not self.waiter_task.done(): self.waiter_task.cancel() for consumer in self.consumers: await consumer.stop() self.consumers = [] async def _get_next_event(self): if self.should_stop: raise StopIteration if not self.registered_consumer: await self._register_consumer() self.waiter_task = asyncio.create_task(asyncio.wait_for(self.trigger_event.wait(), timeout=None)) await self.waiter_task self.trigger_event.clear() @staticmethod def get_description() -> str: return "Will trigger when the price of the given symbol crosses the given price." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return { self.SYMBOL: UI.user_input( self.SYMBOL, commons_enums.UserInputTypes.TEXT, "BTC/USDT", inputs, title="Symbol: symbol to watch price on. Example: ETH/BTC or BTC/USDT:USDT", parent_input_name=step_name, ), self.TARGET_PRICE: UI.user_input( self.TARGET_PRICE, commons_enums.UserInputTypes.FLOAT, 300, inputs, title="Target price: price triggering the event.", parent_input_name=step_name, ), self.MAX_TRIGGER_FREQUENCY: UI.user_input( self.MAX_TRIGGER_FREQUENCY, commons_enums.UserInputTypes.FLOAT, 0.0, inputs, title="Maximum trigger frequency: required time between each trigger. In seconds. " "Useful to avoid spamming in certain situations.", parent_input_name=step_name, ), self.TRIGGER_ONLY_ONCE: UI.user_input( self.TRIGGER_ONLY_ONCE, commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Trigger only once: can only trigger once until OctoBot restart or " "the automation configuration changes.", parent_input_name=step_name, ), } def apply_config(self, config): self.trigger_event.clear() self.last_price = None self.symbol = config[self.SYMBOL] self.target_price = decimal.Decimal(str(config[self.TARGET_PRICE])) self.trigger_only_once = config[self.TRIGGER_ONLY_ONCE] self.max_trigger_frequency = config[self.MAX_TRIGGER_FREQUENCY] ================================================ FILE: Automation/trigger_events/profitability_threshold_event/__init__.py ================================================ from .profitability_threshold import ProfitabilityThreshold ================================================ FILE: Automation/trigger_events/profitability_threshold_event/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ProfitabilityThreshold"], "tentacles-requirements": [] } ================================================ FILE: Automation/trigger_events/profitability_threshold_event/profitability_threshold.py ================================================ # This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) # Copyright (c) 2023 Drakkar-Software, All rights reserved. # # OctoBot is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # OctoBot is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public # License along with OctoBot. If not, see . import asyncio import decimal import time import sortedcontainers import async_channel.enums as channel_enums import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.configuration as configuration import octobot_commons.channels_name as channels_name import octobot.automation.bases.abstract_trigger_event as abstract_trigger_event import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.api as trading_api import octobot_trading.constants as trading_constants class ProfitabilityThreshold(abstract_trigger_event.AbstractTriggerEvent): PERCENT_CHANGE = "percent_change" TIME_PERIOD = "time_period" TRIGGER_ONLY_ONCE = "trigger_only_once" MAX_TRIGGER_FREQUENCY = "max_trigger_frequency" def __init__(self): super().__init__() self.waiter_task = None self.percent_change = None self.time_period = None self.profitability_by_time = None self.trigger_event = asyncio.Event() self.registered_consumer = False self.consumers = [] async def _register_consumer(self): self.registered_consumer = True for exchange_id in trading_api.get_exchange_ids(): self.consumers.append( await exchanges_channel.get_chan( channels_name.OctoBotTradingChannelsName.BALANCE_PROFITABILITY_CHANNEL.value, exchange_id ).new_consumer( self.balance_profitability_callback, priority_level=channel_enums.ChannelConsumerPriorityLevels.MEDIUM.value ) ) async def balance_profitability_callback( self, exchange: str, exchange_id: str, profitability, profitability_percent, market_profitability_percent, initial_portfolio_current_profitability, ): if self.should_stop: # do not go any further if the action has been stopped return self._update_profitability_by_time(profitability_percent) self._check_threshold(profitability_percent) def _update_profitability_by_time(self, profitability_percent): self.profitability_by_time[int(time.time())] = profitability_percent current_time = time.time() for profitability_time in list(self.profitability_by_time): if profitability_time - current_time > self.time_period: self.profitability_by_time.pop(profitability_time) def _check_threshold(self, profitability_percent): oldest_compared_profitability = next(iter(self.profitability_by_time.values())) if trading_constants.ZERO < self.percent_change <= profitability_percent - oldest_compared_profitability: # profitability_percent reached or when above self.percent_change self.trigger_event.set() if trading_constants.ZERO > self.percent_change >= profitability_percent - oldest_compared_profitability: # profitability_percent reached or when bellow self.percent_change self.trigger_event.set() async def stop(self): await super().stop() if self.waiter_task is not None and not self.waiter_task.done(): self.waiter_task.cancel() for consumer in self.consumers: await consumer.stop() self.consumers = [] async def _get_next_event(self): if self.should_stop: raise StopIteration if not self.registered_consumer: await self._register_consumer() self.waiter_task = asyncio.create_task(asyncio.wait_for(self.trigger_event.wait(), timeout=None)) await self.waiter_task self.trigger_event.clear() @staticmethod def get_description() -> str: return "Will trigger when profitability reaches the given % change on the given time window. " \ "Example: a Percent change of 10 will trigger the automation if your OctoBot profitability " \ "changes from 0 to 10 or from 30 to 40." def get_user_inputs(self, UI: configuration.UserInputFactory, inputs: dict, step_name: str) -> dict: return { self.PERCENT_CHANGE: UI.user_input( self.PERCENT_CHANGE, commons_enums.UserInputTypes.FLOAT, 35, inputs, title="Percent change: minimum change of % profitability to trigger the automation. " "Can be negative to trigger on losses.", parent_input_name=step_name, ), self.TIME_PERIOD: UI.user_input( self.TIME_PERIOD, commons_enums.UserInputTypes.FLOAT, 300, inputs, title="Time period: maximum time to consider to compute profitability changes. In minutes.", parent_input_name=step_name, ), self.MAX_TRIGGER_FREQUENCY: UI.user_input( self.MAX_TRIGGER_FREQUENCY, commons_enums.UserInputTypes.FLOAT, 0.0, inputs, title="Maximum trigger frequency: required time between each trigger. In seconds. " "Useful to avoid spamming in certain situations.", parent_input_name=step_name, ), self.TRIGGER_ONLY_ONCE: UI.user_input( self.TRIGGER_ONLY_ONCE, commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Trigger only once: can only trigger once until OctoBot restart or " "the automation configuration changes.", parent_input_name=step_name, ), } def apply_config(self, config): self.trigger_event.clear() self.profitability_by_time = sortedcontainers.SortedDict() self.percent_change = decimal.Decimal(str(config[self.PERCENT_CHANGE])) self.time_period = config[self.TIME_PERIOD] * commons_constants.MINUTE_TO_SECONDS self.trigger_only_once = config[self.TRIGGER_ONLY_ONCE] self.max_trigger_frequency = config[self.MAX_TRIGGER_FREQUENCY] ================================================ FILE: Backtesting/collectors/exchanges/exchange_bot_snapshot_data_collector/__init__.py ================================================ from .bot_snapshot_with_history_collector import ExchangeBotSnapshotWithHistoryCollector ================================================ FILE: Backtesting/collectors/exchanges/exchange_bot_snapshot_data_collector/bot_snapshot_with_history_collector.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import copy import os import json import time import shutil import collections import octobot_backtesting.collectors as collector import octobot_backtesting.importers as importers import octobot_backtesting.enums as backtesting_enums import octobot_backtesting.constants as backtesting_constants import octobot_backtesting.errors as backtesting_errors import octobot_commons.errors as commons_errors import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.databases as databases import octobot_backtesting.data as data import octobot_trading.api as trading_api import octobot_trading.errors as trading_errors import tentacles.Backtesting.importers.exchanges.generic_exchange_importer as generic_exchange_importer class ExchangeBotSnapshotWithHistoryCollector(collector.AbstractExchangeBotSnapshotCollector): IMPORTER = generic_exchange_importer.GenericExchangeDataImporter OHLCV = "ohlcv" KLINE = "kline" def __init__(self, config, exchange_name, exchange_type, tentacles_setup_config, symbols, time_frames, use_all_available_timeframes=False, data_format=backtesting_enums.DataFormats.REGULAR_COLLECTOR_DATA, start_timestamp=None, end_timestamp=None): super().__init__(config, exchange_name, exchange_type, tentacles_setup_config, symbols, time_frames, use_all_available_timeframes, data_format=data_format, start_timestamp=start_timestamp, end_timestamp=end_timestamp) self.exchange_type = None self.exchange_manager = None self.fetch_exchange_manager = None self.file_name = data.get_backtesting_file_name(self.__class__, self.get_permanent_file_identifier, data_format=data_format) self.is_creating_database = False self.description = None self.missing_symbols = [] self.fetched_data = { self.OHLCV: {}, self.KLINE: {}, } self.set_file_path() def get_permanent_file_identifier(self): symbols = "-".join(symbol_util.merge_symbol(symbol.symbol_str) for symbol in self.symbols) time_frames = "-".join(tf.value for tf in self.time_frames) return f"{self.exchange_name}{backtesting_constants.BACKTESTING_DATA_FILE_SEPARATOR}" \ f"{symbols}{backtesting_constants.BACKTESTING_DATA_FILE_SEPARATOR}{time_frames}" async def initialize(self): self.create_database() await self.database.initialize() await self._check_database_content() def set_file_path(self) -> None: super().set_file_path() if os.path.isfile(self.file_path): shutil.copy(self.file_path, self.temp_file_path) def finalize_database(self): if os.path.isfile(self.file_path): os.remove(self.file_path) os.rename(self.temp_file_path, self.file_path) async def _check_database_content(self): # load description try: self.description = await data.get_database_description(self.database) found_exchange_name = self.description[backtesting_enums.DataFormatKeys.EXCHANGE.value] found_symbols = [symbol_util.parse_symbol(symbol) for symbol in self.description[backtesting_enums.DataFormatKeys.SYMBOLS.value]] found_time_frames = self.description[backtesting_enums.DataFormatKeys.TIME_FRAMES.value] if found_exchange_name != self.exchange_name: raise backtesting_errors.IncompatibleDatafileError(f"Exchange name in database: {found_exchange_name}, " f"requested exchange: {self.exchange_name}") if found_symbols != self.symbols: raise backtesting_errors.IncompatibleDatafileError(f"Pairs in database: {found_symbols}, " f"requested exchange: {self.symbols}") if found_time_frames != self.time_frames: raise backtesting_errors.IncompatibleDatafileError(f"Time frames name in database: {found_time_frames}, " f"requested exchange: {self.time_frames}") except commons_errors.DatabaseNotFoundError: # newly created datafile self.is_creating_database = True async def start(self): self.should_stop = False should_stop_database = True self.current_step_percent = 0 self.total_steps = len(self.time_frames) * len(self.symbols) try: self.exchange_manager = trading_api.get_exchange_manager_from_exchange_id(self.exchange_id) # use a secondary exchange manager to fetch candles to fix ccxt pagination issues # seen on ccxt 4.1.82 other_config = copy.copy(self.config) other_config[commons_constants.CONFIG_TIME_FRAME] = [] # any value here to avoid crashing self.fetch_exchange_manager = await trading_api.create_exchange_builder(other_config, self.exchange_name) \ .is_simulated() \ .is_rest_only() \ .is_exchange_only() \ .is_future(self.exchange_manager.is_future) \ .disable_trading_mode() \ .use_tentacles_setup_config(self.tentacles_setup_config) \ .build() await self.adapt_timestamps() # create/update description if self.is_creating_database: await self._create_description() else: await self._update_description() self.in_progress = True self.logger.info(f"Start collecting history on {self.exchange_name}") tasks = [] for symbol_index, symbol in enumerate(self.symbols): if symbol in self.missing_symbols: self.logger.error(f"Skipping {symbol} from backtesting data: " f"missing price history on {self.exchange_name}") continue self.logger.info(f"Collecting history for {symbol}...") tasks.append(asyncio.create_task(self.get_ticker_history(self.exchange_name, symbol))) tasks.append(asyncio.create_task(self.get_order_book_history(self.exchange_name, symbol))) tasks.append(asyncio.create_task(self.get_recent_trades_history(self.exchange_name, symbol))) for time_frame_index, time_frame in enumerate(self.time_frames): tasks.append(asyncio.create_task(self.get_ohlcv_history(self.exchange_name, symbol, time_frame))) tasks.append(asyncio.create_task(self.get_kline_history(self.exchange_name, symbol, time_frame))) if symbol_index == time_frame_index == 0: # let tables get created await asyncio.gather(*tasks) tasks = [] if tasks: await asyncio.gather(*tasks) except Exception as err: await self.database.stop() should_stop_database = False # Do not keep errored data file if os.path.isfile(self.temp_file_path): os.remove(self.temp_file_path) if not self.should_stop: self.logger.exception(err, True, f"Error when collecting {self.exchange_name} history for " f"{', '.join([symbol.symbol_str for symbol in self.symbols])}: {err}") raise backtesting_errors.DataCollectorError(err) from err finally: await self.stop(should_stop_database=should_stop_database) async def stop(self, should_stop_database=True): self.should_stop = True if should_stop_database: await self.database.stop() self.finalize_database() await self.fetch_exchange_manager.stop() self.exchange_manager = None self.in_progress = False self.finished = True return self.finished async def _update_description(self): updated_values = {} if self.end_timestamp and int(self.description[backtesting_enums.DataFormatKeys.END_TIMESTAMP.value]) * 1000 < self.end_timestamp: updated_values["end_timestamp"] = int(self.end_timestamp/1000) if self.start_timestamp and int(self.description[backtesting_enums.DataFormatKeys.START_TIMESTAMP.value]) * 1000 > self.start_timestamp: updated_values["start_timestamp"] = int(self.start_timestamp/1000) if updated_values: updated_values["timestamp"] = time.time() await self.database.update(backtesting_enums.DataTables.DESCRIPTION, updated_value_by_column=updated_values, version=self.VERSION, exchange=self.exchange_name, symbols=json.dumps([symbol.symbol_str for symbol in self.symbols]), time_frames=json.dumps([tf.value for tf in self.time_frames])) async def get_ticker_history(self, exchange, symbol): pass async def get_order_book_history(self, exchange, symbol): pass async def get_recent_trades_history(self, exchange, symbol): pass def get_ohlcv_snapshot(self, symbol, time_frame): symbol_data = trading_api.get_symbol_data(self.exchange_manager, str(symbol), allow_creation=False) candles = trading_api.get_symbol_historical_candles(symbol_data, time_frame) return [ [ time_val, candles[commons_enums.PriceIndexes.IND_PRICE_OPEN.value][index], candles[commons_enums.PriceIndexes.IND_PRICE_HIGH.value][index], candles[commons_enums.PriceIndexes.IND_PRICE_LOW.value][index], candles[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value][index], candles[commons_enums.PriceIndexes.IND_PRICE_VOL.value][index], ] for index, time_val in enumerate(candles[commons_enums.PriceIndexes.IND_PRICE_TIME.value]) ] async def collect_historical_ohlcv(self, exchange, symbol, time_frame, time_frame_sec, start_time, end_time, progress_multiplier): last_progress = 0 symbol_id = str(symbol) async for candles in trading_api.get_historical_ohlcv( self.fetch_exchange_manager, symbol_id, time_frame, start_time, end_time ): await self.save_ohlcv( exchange=exchange, cryptocurrency=self.exchange_manager.exchange.get_pair_cryptocurrency(symbol_id), symbol=symbol.symbol_str, time_frame=time_frame, candle=candles, timestamp=[candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] + time_frame_sec for candle in candles], multiple=True ) progress = (candles[-1][commons_enums.PriceIndexes.IND_PRICE_TIME.value] - self.start_timestamp / 1000) / \ ((self.end_timestamp - self.start_timestamp) / 1000) * 100 progress_over_all_steps = progress * progress_multiplier / self.total_steps self.current_step_percent += progress_over_all_steps - last_progress self.logger.debug(f"progress: {self.current_step_percent}%") last_progress = progress_over_all_steps return last_progress def find_candle(self, candles, timestamp): for candle in candles: if candle[-1][commons_enums.PriceIndexes.IND_PRICE_TIME.value] == timestamp: return candle[-1], candle[0] return None, None async def update_ohlcv(self, exchange, symbol, time_frame, time_frame_sec, database_candles, current_bot_candles): to_add_candles = [] symbol_id = str(symbol) for up_to_date_candle in current_bot_candles: current_candle_time = up_to_date_candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] equivalent_db_candle, candle_timestamp = self.find_candle(database_candles, current_candle_time) if equivalent_db_candle is None: to_add_candles.append(up_to_date_candle) elif equivalent_db_candle != up_to_date_candle: updated_value_by_column = { "candle": json.dumps(up_to_date_candle) } await self.database.update(backtesting_enums.ExchangeDataTables.OHLCV, updated_value_by_column=updated_value_by_column, exchange_name=exchange, cryptocurrency= self.exchange_manager.exchange.get_pair_cryptocurrency(symbol_id), symbol=symbol.symbol_str, time_frame=time_frame.value, timestamp=str(candle_timestamp)) if to_add_candles: await self.save_ohlcv( exchange=exchange, cryptocurrency=self.exchange_manager.exchange.get_pair_cryptocurrency(symbol_id), symbol=symbol, time_frame=time_frame, candle=to_add_candles, timestamp=[candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] + time_frame_sec for candle in to_add_candles], multiple=True ) async def _check_ohlcv_integrity(self, database_candles): # ensure no timestamp is here twice all_timestamps = [candle[-1][0] for candle in database_candles] unique_timestamps = set(all_timestamps) if len(unique_timestamps) != len(database_candles): return { timestamp: counter for timestamp, counter in collections.Counter(all_timestamps).items() if counter > 1 } return {} async def get_ohlcv_history(self, exchange, symbol, time_frame): try: last_progress = 0 time_frame_sec = commons_enums.TimeFramesMinutes[time_frame] * commons_constants.MINUTE_TO_SECONDS # use current data from current bot fetch_data_id = self.get_fetch_data_id(symbol, time_frame) already_fetched_candles_candles = self.fetched_data[self.OHLCV][fetch_data_id] database_candles = [] save_all_candles = self.is_creating_database updated_db = False if not self.is_creating_database: database_candles = await self._import_candles_from_datafile(exchange, symbol, time_frame) counters = await self._check_ohlcv_integrity(database_candles) if counters: self.logger.warning(f"Duplicate candles in {exchange} data file for {symbol.symbol_str} " f"on {time_frame}. Problematic timestamps: {counters}. " f"Resetting database to ensure data integrity") await self.delete_all( backtesting_enums.ExchangeDataTables.OHLCV, exchange=exchange, cryptocurrency=self.exchange_manager.exchange.get_pair_cryptocurrency(str(symbol)), symbol=symbol.symbol_str, time_frame=time_frame ) updated_db = True save_all_candles = True if save_all_candles or not database_candles: await self.save_ohlcv( exchange=exchange, cryptocurrency=self.exchange_manager.exchange.get_pair_cryptocurrency(str(symbol)), symbol=symbol.symbol_str, time_frame=time_frame, candle=already_fetched_candles_candles, timestamp=[candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] + time_frame_sec for candle in already_fetched_candles_candles], multiple=True ) database_candles = await self._import_candles_from_datafile(exchange, symbol, time_frame) updated_db = True candle_times = [ candle[-1][commons_enums.PriceIndexes.IND_PRICE_TIME.value] for candle in database_candles ] # +/-1 not to fetch the last candle twice first_candle_data_time = min(candle_times) * 1000 - 1 last_candle_data_time = max(candle_times) * 1000 + 1 fill_before = self.start_timestamp and self.start_timestamp + time_frame_sec * 1000 < first_candle_data_time fill_after = last_candle_data_time < self.end_timestamp progress_per_collect = 0.5 if fill_after and fill_before else 1 # 1. fill in any missing candle before existing candles if fill_before: # fetch missing data between required start time and actual start time in data file last_progress = await self.collect_historical_ohlcv( exchange, symbol, time_frame, time_frame_sec, self.start_timestamp, first_candle_data_time, progress_per_collect ) if last_progress: self.current_step_percent += 100 * progress_per_collect / self.total_steps - last_progress updated_db = True # 2. fill in any missing candle after existing candles if fill_after: # fetch missing data between end time in data file and available data last_progress = await self.collect_historical_ohlcv( exchange, symbol, time_frame, time_frame_sec, last_candle_data_time, self.end_timestamp, progress_per_collect ) if last_progress: self.current_step_percent += 100 * progress_per_collect / self.total_steps - last_progress updated_db = True if not (fill_before or fill_after): # nothing to collect, update progress still self.current_step_percent += 100 / self.total_steps if updated_db: database_candles = await self._import_candles_from_datafile(exchange, symbol, time_frame) counters = await self._check_ohlcv_integrity(database_candles) if counters: self.logger.error(f"Error when checking database integrity of {exchange} " f"data file for {symbol.symbol_str}. " f"Delete this data file: {self.file_name} to reset it. " f"Problematic timestamps: {counters}") except Exception: raise async def _import_candles_from_datafile(self, exchange, symbol, time_frame): return importers.import_ohlcvs( await self.database.select(backtesting_enums.ExchangeDataTables.OHLCV, size=databases.SQLiteDatabase.DEFAULT_SIZE, exchange_name=exchange, symbol=symbol.symbol_str, time_frame=time_frame.value) ) async def get_kline_history(self, exchange, symbol, time_frame): pass async def adapt_timestamps(self): lowest_timestamps = [] for symbol in self.symbols: for tf in self.time_frames: first_timestamp = await self.get_first_candle_timestamp( self.start_timestamp, symbol, tf ) if first_timestamp is None: self.missing_symbols.append(symbol) break else: lowest_timestamps.append(first_timestamp) lowest_timestamp = min(lowest_timestamps) # lowest_timestamp depends on self.start_timestamp if set. It will not go further if self.start_timestamp is None or lowest_timestamp < self.start_timestamp: self.start_timestamp = lowest_timestamp self.end_timestamp = self.end_timestamp or time.time() * 1000 if self.start_timestamp > self.end_timestamp: raise backtesting_errors.DataCollectorError("start_timestamp is higher than end_timestamp") def get_fetch_data_id(self, symbol, timeframe): return f"{symbol}{timeframe.value}" async def get_first_candle_timestamp(self, ideal_start_timestamp, symbol, time_frame): try: symbol_data = trading_api.get_symbol_data(self.exchange_manager, str(symbol), allow_creation=False) candles = trading_api.get_symbol_historical_candles(symbol_data, time_frame) self.fetched_data[self.OHLCV][self.get_fetch_data_id(symbol, time_frame)] = self.get_ohlcv_snapshot( symbol, time_frame ) return candles[commons_enums.PriceIndexes.IND_PRICE_TIME.value][0] * 1000 except KeyError: # symbol or timeframe not available in live exchange fetched_candles = await self.fetch_exchange_manager.exchange.get_symbol_prices( str(symbol), time_frame, limit=1, since=ideal_start_timestamp ) if not fetched_candles: return None self.fetched_data[self.OHLCV][self.get_fetch_data_id(symbol, time_frame)] = fetched_candles return fetched_candles[0][commons_enums.PriceIndexes.IND_PRICE_TIME.value] * 1000 ================================================ FILE: Backtesting/collectors/exchanges/exchange_bot_snapshot_data_collector/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ExchangeBotSnapshotCollector"], "tentacles-requirements": [] } ================================================ FILE: Backtesting/collectors/exchanges/exchange_history_collector/__init__.py ================================================ from .history_collector import ExchangeHistoryDataCollector ================================================ FILE: Backtesting/collectors/exchanges/exchange_history_collector/history_collector.pxd ================================================ # cython: language_level=3 # Drakkar-Software OctoBot-Backtesting # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from octobot_backtesting.collectors.exchanges.exchange_collector cimport AbstractExchangeHistoryCollector cdef class ExchangeHistoryDataCollector(AbstractExchangeHistoryCollector): cdef public object exchange cdef public object exchange_manager ================================================ FILE: Backtesting/collectors/exchanges/exchange_history_collector/history_collector.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import logging import os import time import octobot_backtesting.collectors as collector import octobot_backtesting.enums as backtesting_enums import octobot_backtesting.errors as errors import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.time_frame_manager as time_frame_manager import tentacles.Backtesting.importers.exchanges.generic_exchange_importer as generic_exchange_importer try: import octobot_trading.api as trading_api import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors except ImportError: logging.error("ExchangeHistoryDataCollector requires OctoBot-Trading package installed") class ExchangeHistoryDataCollector(collector.AbstractExchangeHistoryCollector): IMPORTER = generic_exchange_importer.GenericExchangeDataImporter def __init__(self, config, exchange_name, exchange_type, tentacles_setup_config, symbols, time_frames, use_all_available_timeframes=False, data_format=backtesting_enums.DataFormats.REGULAR_COLLECTOR_DATA, start_timestamp=None, end_timestamp=None): super().__init__(config, exchange_name, exchange_type, tentacles_setup_config, symbols, time_frames, use_all_available_timeframes, data_format=data_format, start_timestamp=start_timestamp, end_timestamp=end_timestamp) self.exchange = None self.exchange_manager = None async def start(self): self.should_stop = False should_stop_database = True try: use_future = self.exchange_type == trading_enums.ExchangeTypes.FUTURE self.exchange_manager = await trading_api.create_exchange_builder(self.config, self.exchange_name) \ .is_simulated() \ .is_rest_only() \ .is_exchange_only() \ .is_future(use_future) \ .disable_trading_mode() \ .use_tentacles_setup_config(self.tentacles_setup_config) \ .build() self.exchange = self.exchange_manager.exchange self._load_timeframes_if_necessary() await self.check_timestamps() # create description await self._create_description() self.total_steps = len(self.time_frames) * len(self.symbols) self.in_progress = True self.logger.info(f"Start collecting history on {self.exchange_name}") for symbol_index, symbol in enumerate(self.symbols): self.logger.info(f"Collecting history for {symbol}...") await self.get_ticker_history(self.exchange_name, symbol) await self.get_order_book_history(self.exchange_name, symbol) await self.get_recent_trades_history(self.exchange_name, symbol) for time_frame_index, time_frame in enumerate(self.time_frames): self.current_step_index = (symbol_index * len(self.time_frames)) + time_frame_index + 1 self.logger.info( f"[{time_frame_index}/{len(self.time_frames)}] Collecting {symbol} history on {time_frame}...") await self.get_ohlcv_history(self.exchange_name, symbol, time_frame) await self.get_kline_history(self.exchange_name, symbol, time_frame) except Exception as err: await self.database.stop() should_stop_database = False # Do not keep errored data file if os.path.isfile(self.temp_file_path): os.remove(self.temp_file_path) if not self.should_stop: self.logger.exception(err, True, f"Error when collecting {self.exchange_name} history for " f"{', '.join([str(symbol) for symbol in self.symbols])}: {err}") raise errors.DataCollectorError(err) finally: await self.stop(should_stop_database=should_stop_database) def _load_all_available_timeframes(self): allowed_timeframes = set(tf.value for tf in commons_enums.TimeFrames) self.time_frames = [commons_enums.TimeFrames(time_frame) for time_frame in self.exchange_manager.client_time_frames if time_frame in allowed_timeframes] async def stop(self, should_stop_database=True): self.should_stop = True if self.exchange_manager is not None: await self.exchange_manager.stop() if should_stop_database: await self.database.stop() self.finalize_database() self.exchange_manager = None self.in_progress = False self.finished = True return self.finished async def get_ticker_history(self, exchange, symbol): pass async def get_order_book_history(self, exchange, symbol): pass async def get_recent_trades_history(self, exchange, symbol): pass async def get_ohlcv_history(self, exchange, symbol, time_frame): self.current_step_percent = 0 # use time_frame_sec to add time to save the candle closing time time_frame_sec = commons_enums.TimeFramesMinutes[time_frame] * commons_constants.MINUTE_TO_SECONDS symbol_id = str(symbol) cryptocurrency = self.exchange_manager.exchange.get_pair_cryptocurrency(symbol_id) if self.start_timestamp is not None: start_time = self.start_timestamp end_time = self.end_timestamp or time.time() * 1000 first_candle_timestamp = await self.get_first_candle_timestamp( self.start_timestamp, symbol, time_frame ) * 1000 if self.start_timestamp < first_candle_timestamp: start_time = first_candle_timestamp async for hist_candles in trading_api.get_historical_ohlcv(self.exchange_manager, symbol_id, time_frame, start_time, end_time): if hist_candles: self.current_step_percent = \ (hist_candles[-1][commons_enums.PriceIndexes.IND_PRICE_TIME.value] - start_time / 1000) / \ ((end_time - start_time) / 1000) * 100 self.logger.info(f"[{self.current_step_percent}%] historical data fetched for {symbol} {time_frame}") await self.save_ohlcv( exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol.symbol_str, time_frame=time_frame, candle=hist_candles, timestamp=[candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] + time_frame_sec for candle in hist_candles], multiple=True) else: try: candles = await self.exchange.get_symbol_prices(symbol_id, time_frame) if candles: await self.save_ohlcv(exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol.symbol_str, time_frame=time_frame, candle=candles, timestamp=[candle[0] + time_frame_sec for candle in candles], multiple=True) else: self.logger.error(f"No candles for {symbol} on {time_frame} ({exchange})") except trading_errors.FailedRequest as err: self.logger.exception(err, False) self.logger.warning(f"Ignored {symbol} {time_frame} candles on {exchange} ({err})") async def get_kline_history(self, exchange, symbol, time_frame): pass async def check_timestamps(self): if self.start_timestamp is not None: lowest_timestamp = min([ await self.get_first_candle_timestamp( self.start_timestamp, symbol, time_frame_manager.find_min_time_frame(self.time_frames) ) for symbol in self.symbols ]) if lowest_timestamp > self.start_timestamp: self.start_timestamp = lowest_timestamp if self.start_timestamp > (self.end_timestamp if self.end_timestamp else (time.time() * 1000)): raise errors.DataCollectorError("start_timestamp is higher than end_timestamp") async def get_first_candle_timestamp(self, ideal_start_timestamp, symbol, time_frame): try: return ( await self.exchange.get_symbol_prices(str(symbol), time_frame, limit=1, since=ideal_start_timestamp) )[0][commons_enums.PriceIndexes.IND_PRICE_TIME.value] except (trading_errors.FailedRequest, IndexError) as err: raise errors.DataCollectorError( f"Impossible to initialize {self.exchange_name} data collector: {err}. This means that {symbol} " f"for the {time_frame.value} time frame is not supported in this context on {self.exchange_name}." ) ================================================ FILE: Backtesting/collectors/exchanges/exchange_history_collector/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ExchangeHistoryDataCollector"], "tentacles-requirements": [] } ================================================ FILE: Backtesting/collectors/exchanges/exchange_history_collector/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Backtesting/collectors/exchanges/exchange_history_collector/tests/test_history_collector.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import os import contextlib import json import asyncio import octobot_commons.databases as databases import octobot_commons.symbols as commons_symbols import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_backtesting.enums as enums import octobot_backtesting.errors as errors import octobot_trading.enums as trading_enums import tests.test_utils.config as test_utils_config import tentacles.Backtesting.collectors.exchanges as collector_exchanges import tentacles.Trading.Exchange as tentacles_exchanges # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio BINANCEUS = "binanceus" BINANCEUS_MAX_CANDLES_COUNT = 500 @contextlib.asynccontextmanager async def data_collector(exchange_name, tentacles_setup_config, symbols, time_frames, use_all_available_timeframes, start_timestamp=None, end_timestamp=None): collector_instance = collector_exchanges.ExchangeHistoryDataCollector( {}, exchange_name, trading_enums.ExchangeTypes.SPOT, tentacles_setup_config, [commons_symbols.parse_symbol(symbol) for symbol in symbols], time_frames, use_all_available_timeframes=use_all_available_timeframes, start_timestamp=start_timestamp, end_timestamp=end_timestamp ) try: await collector_instance.initialize() yield collector_instance finally: if collector_instance.file_path and os.path.isfile(collector_instance.file_path): os.remove(collector_instance.file_path) if collector_instance.temp_file_path and os.path.isfile(collector_instance.temp_file_path): os.remove(collector_instance.temp_file_path) @contextlib.asynccontextmanager async def collector_database(collector): database = databases.SQLiteDatabase(collector.file_path) try: await database.initialize() yield database finally: await database.stop() async def test_collect_valid_data(): tentacles_setup_config = test_utils_config.load_test_tentacles_config() symbols = ["ETH/BTC"] async with data_collector(BINANCEUS, tentacles_setup_config, symbols, None, True) as collector: assert collector.time_frames == [] assert collector.symbols == [commons_symbols.parse_symbol(symbol) for symbol in symbols] assert collector.exchange_name == BINANCEUS assert collector.tentacles_setup_config == tentacles_setup_config await collector.start() assert collector.time_frames != [] assert collector.exchange_manager is None assert isinstance(collector.exchange, tentacles_exchanges.BinanceUS) assert collector.file_path is not None assert collector.temp_file_path is not None assert not os.path.isfile(collector.temp_file_path) assert os.path.isfile(collector.file_path) async with collector_database(collector) as database: ohlcv = await database.select(enums.ExchangeDataTables.OHLCV) # use > to take into account new possible candles since collect max time is not specified assert len(ohlcv) > 6000 h_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, time_frame="1h") assert len(h_ohlcv) == BINANCEUS_MAX_CANDLES_COUNT eth_btc_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, symbol="ETH/BTC") assert len(eth_btc_ohlcv) == len(ohlcv) async def test_collect_invalid_data(): tentacles_setup_config = test_utils_config.load_test_tentacles_config() symbols = ["___ETH/BTC"] async with data_collector(BINANCEUS, tentacles_setup_config, symbols, None, True) as collector: with pytest.raises(errors.DataCollectorError): await collector.start() assert collector.time_frames != [] assert collector.exchange_manager is None assert collector.exchange is not None assert collector.file_path is not None assert collector.temp_file_path is not None assert not os.path.isfile(collector.temp_file_path) async def test_collect_valid_date_range(): tentacles_setup_config = test_utils_config.load_test_tentacles_config() symbols = ["ETH/BTC"] start_time = 1569413160000 end_time = 1569914160000 # each request fetches 500 candles candle_fetch_limit = 500 async with data_collector(BINANCEUS, tentacles_setup_config, symbols, None, True, start_time, end_time) as collector: assert collector.start_timestamp is not None assert collector.end_timestamp is not None await collector.start() assert collector.time_frames != [] assert collector.exchange_manager is None assert isinstance(collector.exchange, tentacles_exchanges.BinanceUS) assert collector.file_path is not None assert collector.temp_file_path is not None assert os.path.isfile(collector.file_path) assert not os.path.isfile(collector.temp_file_path) async with collector_database(collector) as database: ohlcv = await database.select(enums.ExchangeDataTables.OHLCV) assert len(ohlcv) == 13943 parsed_candles = [ json.loads(candle[-1]) for candle in ohlcv ] for parsed_candle in parsed_candles: candle_open_time = parsed_candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] assert start_time <= candle_open_time * 1000 <= end_time for time_frame in commons_enums.TimeFrames: time_frame_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, time_frame=time_frame.value) if not time_frame_ohlcv: continue all_timestamps = sorted([ candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] for candle in ( json.loads(candle[-1]) for candle in time_frame_ohlcv ) ]) # ensure no duplicate timestamps = set(all_timestamps) assert len(timestamps) == len(time_frame_ohlcv) # ensure no missing interval = commons_enums.TimeFramesMinutes[time_frame] * commons_constants.MINUTE_TO_SECONDS current_ts = all_timestamps[0] - interval for timestamp in all_timestamps: current_ts += interval assert timestamp == current_ts h_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, time_frame=commons_enums.TimeFrames.ONE_HOUR.value) assert len(h_ohlcv) == 139 eth_btc_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, symbol="ETH/BTC") assert len(eth_btc_ohlcv) == len(ohlcv) min_timestamp = (await database.select_min(enums.ExchangeDataTables.OHLCV, ["timestamp"], time_frame=commons_enums.TimeFrames.ONE_MINUTE.value))[0][ commons_enums.PriceIndexes.IND_PRICE_TIME.value] * 1000 assert start_time <= min_timestamp <= start_time + (60 * 1000) max_timestamp = (await database.select_max(enums.ExchangeDataTables.OHLCV, ["timestamp"]))[0][ commons_enums.PriceIndexes.IND_PRICE_TIME.value] * 1000 assert end_time <= max_timestamp <= end_time + (31 * 24 * 60 * 60 * 1000) async def test_collect_invalid_date_range(): tentacles_setup_config = test_utils_config.load_test_tentacles_config() symbols = ["ETH/BTC"] async with data_collector(BINANCEUS, tentacles_setup_config, symbols, None, True, 1609459200, 1577836800) \ as collector: assert collector.start_timestamp is not None assert collector.end_timestamp is not None with pytest.raises(errors.DataCollectorError): await collector.start() assert collector.time_frames != [] assert collector.exchange_manager is None assert isinstance(collector.exchange, tentacles_exchanges.BinanceUS) assert collector.file_path is not None assert collector.temp_file_path is not None assert not os.path.isfile(collector.file_path) assert not os.path.isfile(collector.temp_file_path) async def test_collect_multi_pair(): tentacles_setup_config = test_utils_config.load_test_tentacles_config() symbols = ["ETH/BTC", "BTC/USDT", "LTC/BTC"] async with data_collector(BINANCEUS, tentacles_setup_config, symbols, None, True) as collector: assert collector.time_frames == [] assert collector.symbols == [commons_symbols.parse_symbol(symbol) for symbol in symbols] assert collector.exchange_name == BINANCEUS assert collector.tentacles_setup_config == tentacles_setup_config await collector.start() assert collector.time_frames != [] assert collector.exchange_manager is None assert isinstance(collector.exchange, tentacles_exchanges.BinanceUS) assert collector.file_path is not None assert collector.temp_file_path is not None assert not os.path.isfile(collector.temp_file_path) assert os.path.isfile(collector.file_path) async with collector_database(collector) as database: ohlcv = await database.select(enums.ExchangeDataTables.OHLCV) # use > to take into account new possible candles since collect max time is not specified assert len(ohlcv) > 19316 h_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, time_frame="4h") assert len(h_ohlcv) == len(symbols) * BINANCEUS_MAX_CANDLES_COUNT symbols_description = json.loads((await database.select(enums.DataTables.DESCRIPTION))[0][3]) assert all(symbol in symbols_description for symbol in symbols) eth_btc_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, symbol="ETH/BTC") assert len(eth_btc_ohlcv) > 6598 inch_btc_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, symbol="LTC/BTC") assert len(inch_btc_ohlcv) > 5803 btc_usdt_ohlcv = await database.select(enums.ExchangeDataTables.OHLCV, symbol="BTC/USDT") assert len(btc_usdt_ohlcv) > 6598 async def test_stop_collect(): tentacles_setup_config = test_utils_config.load_test_tentacles_config() symbols = ["AAVE/USDT"] async with data_collector(BINANCEUS, tentacles_setup_config, symbols, None, True, 1549065660000, 1632090006000) as collector: async def stop_soon(): await asyncio.sleep(5) await collector.stop(should_stop_database=False) await asyncio.gather(collector.start(), stop_soon()) assert collector.time_frames != [] assert collector.symbols == [commons_symbols.parse_symbol(symbol) for symbol in symbols] assert collector.exchange_name == BINANCEUS assert collector.tentacles_setup_config == tentacles_setup_config assert collector.finished assert collector.exchange_manager is None assert not os.path.isfile(collector.temp_file_path) assert not os.path.isfile(collector.file_path) ================================================ FILE: Backtesting/collectors/exchanges/exchange_live_collector/__init__.py ================================================ from .live_collector import ExchangeLiveDataCollector ================================================ FILE: Backtesting/collectors/exchanges/exchange_live_collector/live_collector.pxd ================================================ # cython: language_level=3 # Drakkar-Software OctoBot-Backtesting # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from octobot_backtesting.collectors.exchanges.exchange_collector cimport ExchangeDataCollector cdef class ExchangeLiveDataCollector(ExchangeDataCollector): pass ================================================ FILE: Backtesting/collectors/exchanges/exchange_live_collector/live_collector.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import logging import time import octobot_backtesting.collectors.exchanges as exchanges import octobot_commons.channels_name as channels_name import tentacles.Backtesting.importers.exchanges.generic_exchange_importer as generic_exchange_importer try: import octobot_trading.exchange_channel as exchange_channel import octobot_trading.api as trading_api except ImportError: logging.error("ExchangeLiveDataCollector requires OctoBot-Trading package installed") class ExchangeLiveDataCollector(exchanges.AbstractExchangeLiveCollector): IMPORTER = generic_exchange_importer.GenericExchangeDataImporter async def start(self): exchange_manager = await trading_api.create_exchange_builder(self.config, self.exchange_name) \ .is_simulated() \ .is_rest_only() \ .is_without_auth() \ .is_ignoring_config() \ .disable_trading_mode() \ .use_tentacles_setup_config(self.tentacles_setup_config) \ .build() self._load_timeframes_if_necessary() # create description await self._create_description() exchange_id = exchange_manager.id await exchange_channel.get_chan(channels_name.OctoBotTradingChannelsName.TICKER_CHANNEL.value, exchange_id).new_consumer(self.ticker_callback) await exchange_channel.get_chan(channels_name.OctoBotTradingChannelsName.RECENT_TRADES_CHANNEL.value, exchange_id).new_consumer(self.recent_trades_callback) await exchange_channel.get_chan(channels_name.OctoBotTradingChannelsName.ORDER_BOOK_CHANNEL.value, exchange_id).new_consumer(self.order_book_callback) await exchange_channel.get_chan(channels_name.OctoBotTradingChannelsName.KLINE_CHANNEL.value, exchange_id).new_consumer(self.kline_callback) await exchange_channel.get_chan(channels_name.OctoBotTradingChannelsName.OHLCV_CHANNEL.value, exchange_id).new_consumer(self.ohlcv_callback) await asyncio.gather(*asyncio.all_tasks(asyncio.get_event_loop())) async def ticker_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, ticker): self.logger.info(f"TICKER : CRYPTOCURRENCY = {cryptocurrency} || SYMBOL = {symbol} || TICKER = {ticker}") await self.save_ticker(timestamp=time.time(), exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol, ticker=ticker) async def order_book_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, asks, bids): self.logger.info(f"ORDERBOOK : CRYPTOCURRENCY = {cryptocurrency} || SYMBOL = {symbol} " f"|| ASKS = {asks} || BIDS = {bids}") await self.save_order_book(timestamp=time.time(), exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol, asks=asks, bids=bids) async def recent_trades_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, recent_trades): self.logger.info(f"RECENT TRADE : CRYPTOCURRENCY = {cryptocurrency} || SYMBOL = {symbol} " f"|| RECENT TRADE = {recent_trades}") await self.save_recent_trades(timestamp=time.time(), exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol, recent_trades=recent_trades) async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle): self.logger.info(f"OHLCV : CRYPTOCURRENCY = {cryptocurrency} || SYMBOL = {symbol} " f"|| TIME FRAME = {time_frame} || CANDLE = {candle}") await self.save_ohlcv(timestamp=time.time(), exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol, time_frame=time_frame, candle=candle) async def kline_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, kline): self.logger.info(f"KLINE : CRYPTOCURRENCY = {cryptocurrency} || SYMBOL = {symbol} " f"|| TIME FRAME = {time_frame} || KLINE = {kline}") await self.save_kline(timestamp=time.time(), exchange=exchange, cryptocurrency=cryptocurrency, symbol=symbol, time_frame=time_frame, kline=kline) ================================================ FILE: Backtesting/collectors/exchanges/exchange_live_collector/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ExchangeLiveDataCollector"], "tentacles-requirements": [] } ================================================ FILE: Backtesting/converters/exchanges/legacy_data_converter/__init__.py ================================================ from .legacy_converter import LegacyDataConverter ================================================ FILE: Backtesting/converters/exchanges/legacy_data_converter/legacy_converter.pxd ================================================ # cython: language_level=3 # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from octobot_backtesting.converters.data_converter cimport DataConverter from octobot_backtesting.data.database cimport DataBase cdef class LegacyDataConverter(DataConverter): cdef str exchange_name cdef str symbol cdef str time_data cdef list time_frames cdef dict file_content cdef DataBase database cdef list _get_formatted_candles(self, object time_frame) cdef dict _read_data_file(self) cdef dict _read_data_file(self) ================================================ FILE: Backtesting/converters/exchanges/legacy_data_converter/legacy_converter.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import gzip import json import enum import os.path as path import datetime import octobot_backtesting.collectors.exchanges as exchanges import octobot_backtesting.constants as backtesting_constants import octobot_backtesting.converters as converters import octobot_backtesting.data as backtesting_data import octobot_backtesting.enums as backtesting_enums import octobot_commons.databases as databases import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.symbols.symbol_util as symbol_util class LegacyDataConverter(converters.DataConverter): """ LegacyDataConverter can be used to convert OctoBot v0.3 data files into v0.4 data files. """ DATA_FILE_EXT = ".data" VERSION = "1.0" DATA_FILE_TIME_DATE_FORMAT = '%Y%m%d%H%M%S' class PriceIndexes(enum.Enum): IND_PRICE_TIME = 0 IND_PRICE_OPEN = 1 IND_PRICE_HIGH = 2 IND_PRICE_LOW = 3 IND_PRICE_CLOSE = 4 IND_PRICE_VOL = 5 def __init__(self, backtesting_file_to_convert): super().__init__(backtesting_file_to_convert) self.exchange_name = "" self.symbol = "" self.time_data = "" self.time_frames = [] self.file_content = {} self.database = None self.converted_file = backtesting_data.get_backtesting_file_name(exchanges.AbstractExchangeHistoryCollector) async def can_convert(self, ) -> bool: self.exchange_name, self.symbol, self.time_data = LegacyDataConverter._interpret_file_name(self.file_to_convert) if None in (self.exchange_name, self.symbol, self.time_data): return False self.file_content = self._read_data_file() if not self.file_content: return False for time_frame, candles_data in self.file_content.items(): try: # check time frame validity time_frame = commons_enums.TimeFrames(time_frame) # check candle data validity if isinstance(candles_data, list) and len(candles_data) == 6: # check candle data non-emptiness if all(data for data in candles_data): self.time_frames.append(time_frame) except ValueError: pass return bool(self.time_frames) async def convert(self) -> bool: try: self.database = databases.SQLiteDatabase( path.join(backtesting_constants.BACKTESTING_FILE_PATH, self.converted_file)) await self.database.initialize() await self._create_description() for time_frame in self.time_frames: await self._convert_ohlcv(time_frame) return True except Exception as e: self.logger.exception(e, True, f"Error while converting data file: {e}") return False finally: if self.database is not None: await self.database.stop() async def _create_description(self): time_object = datetime.datetime.strptime(self.time_data, self.DATA_FILE_TIME_DATE_FORMAT) await self.database.insert(backtesting_enums.DataTables.DESCRIPTION, timestamp=datetime.datetime.timestamp(time_object), version=self.VERSION, exchange=self.exchange_name, symbols=json.dumps([self.symbol]), time_frames=json.dumps([tf.value for tf in self.time_frames])) async def _convert_ohlcv(self, time_frame): # use time_frame_sec to add time to save the candle closing time time_frame_sec = commons_enums.TimeFramesMinutes[time_frame] * commons_constants.MINUTE_TO_SECONDS candles = self._get_formatted_candles(time_frame) await self.database.insert_all(backtesting_enums.ExchangeDataTables.OHLCV, timestamp=[candle[0] + time_frame_sec for candle in candles], exchange_name=self.exchange_name, symbol=self.symbol, time_frame=time_frame.value, candle=[json.dumps(c) for c in candles]) def _get_formatted_candles(self, time_frame): data = self.file_content[time_frame.value] candles = [] for i in range(len(data[LegacyDataConverter.PriceIndexes.IND_PRICE_TIME.value])): candles.insert(i, [None] * len(LegacyDataConverter.PriceIndexes)) candles[i][LegacyDataConverter.PriceIndexes.IND_PRICE_CLOSE.value] = \ data[LegacyDataConverter.PriceIndexes.IND_PRICE_CLOSE.value][i] candles[i][LegacyDataConverter.PriceIndexes.IND_PRICE_OPEN.value] = \ data[LegacyDataConverter.PriceIndexes.IND_PRICE_OPEN.value][i] candles[i][LegacyDataConverter.PriceIndexes.IND_PRICE_HIGH.value] = \ data[LegacyDataConverter.PriceIndexes.IND_PRICE_HIGH.value][i] candles[i][LegacyDataConverter.PriceIndexes.IND_PRICE_LOW.value] = \ data[LegacyDataConverter.PriceIndexes.IND_PRICE_LOW.value][i] candles[i][LegacyDataConverter.PriceIndexes.IND_PRICE_TIME.value] = \ data[LegacyDataConverter.PriceIndexes.IND_PRICE_TIME.value][i] candles[i][LegacyDataConverter.PriceIndexes.IND_PRICE_VOL.value] = \ data[LegacyDataConverter.PriceIndexes.IND_PRICE_VOL.value][i] return candles def _read_data_file(self): try: # try zipfile with gzip.open(self.file_to_convert, 'r') as file_to_parse: file_content = json.loads(file_to_parse.read()) except OSError: # try without unzip with open(self.file_to_convert) as file_to_parse: file_content = json.loads(file_to_parse.read()) except Exception: return {} return file_content @staticmethod def _interpret_file_name(file_name): data = path.basename(file_name).split("_") try: exchange_name = data[0] symbol = symbol_util.merge_currencies(data[1], data[2]) file_ext = LegacyDataConverter.DATA_FILE_EXT timestamp = data[3] + data[4].replace(file_ext, "") except KeyError: exchange_name = None symbol = None timestamp = None return exchange_name, symbol, timestamp ================================================ FILE: Backtesting/converters/exchanges/legacy_data_converter/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["LegacyDataConverter"], "tentacles-requirements": [] } ================================================ FILE: Backtesting/importers/exchanges/generic_exchange_importer/__init__.py ================================================ from .generic_exchange_importer import GenericExchangeDataImporter ================================================ FILE: Backtesting/importers/exchanges/generic_exchange_importer/generic_exchange_importer.pxd ================================================ # cython: language_level=3 # Drakkar-Software OctoBot-Backtesting # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from octobot_backtesting.importers.exchanges.exchange_importer cimport ExchangeDataImporter cdef class GenericExchangeDataImporter(ExchangeDataImporter): pass ================================================ FILE: Backtesting/importers/exchanges/generic_exchange_importer/generic_exchange_importer.py ================================================ # Drakkar-Software OctoBot-Backtesting # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_backtesting.importers as importers class GenericExchangeDataImporter(importers.ExchangeDataImporter): pass ================================================ FILE: Backtesting/importers/exchanges/generic_exchange_importer/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GenericExchangeDataImporter"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/__init__.py ================================================ from .instant_fluctuations import InstantFluctuationsEvaluator, InstantMAEvaluator ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/config/InstantFluctuationsEvaluator.json ================================================ { "price_difference_threshold_percent": 1, "volume_difference_threshold_percent": 400, "time_frame": "1m" } ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/config/InstantMAEvaluator.json ================================================ { "period": 6, "time_frame": "1m", "threshold": 0.5 } ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/instant_fluctuations.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import math import tulipy import numpy as np import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.channels_name as channels_name import octobot_evaluators.evaluators as evaluators import octobot_evaluators.util as evaluators_util class InstantFluctuationsEvaluator(evaluators.RealTimeEvaluator): """ Idea: moves are lasting approx 12min Check the last 12 candles and compute mean closing prices as well as mean volume with a gradually narrower interval to compute the strength or weakness of the move """ PRICE_THRESHOLD_KEY = "price_difference_threshold_percent" VOLUME_THRESHOLD_KEY = "volume_difference_threshold_percent" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.something_is_happening = False self.last_notification_eval = 0 self.average_prices = {} self.last_price = 0 # Volume self.average_volumes = {} self.last_volume = 0 # Constants self.time_frame = None self.VOLUME_HAPPENING_THRESHOLD = None self.PRICE_HAPPENING_THRESHOLD = None self.MIN_TRIGGERING_DELTA = 0.15 self.candle_segments = [10, 8, 6, 5, 4, 3, 2, 1] def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.time_frame = self.time_frame or \ self.UI.user_input(commons_constants.CONFIG_TIME_FRAME, commons_enums.UserInputTypes.OPTIONS, commons_enums.TimeFrames.ONE_MINUTE.value, inputs, options=[tf.value for tf in commons_enums.TimeFrames], title="Time frame: The time frame to observe in order to spot changes.") self.VOLUME_HAPPENING_THRESHOLD = 1 + self.UI.user_input( self.VOLUME_THRESHOLD_KEY, commons_enums.UserInputTypes.FLOAT, 400, inputs, min_val=0, title="Volume threshold: volume difference in percent from which to trigger a notification." ) / 100 self.PRICE_HAPPENING_THRESHOLD = self.UI.user_input( self.PRICE_THRESHOLD_KEY, commons_enums.UserInputTypes.FLOAT, 1, inputs, min_val=0, title="Price threshold: price difference in percent from which to trigger a notification." ) / 100 async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle): volume_data = self.get_symbol_candles(exchange, exchange_id, symbol, time_frame). \ get_symbol_volume_candles(self.candle_segments[0]) close_data = self.get_symbol_candles(exchange, exchange_id, symbol, time_frame). \ get_symbol_close_candles(self.candle_segments[0]) for segment in self.candle_segments: volume_data = [d for d in volume_data[-segment:] if d is not None] price_data = [d for d in close_data[-segment:] if d is not None] self.average_volumes[segment] = np.mean(volume_data) self.average_prices[segment] = np.mean(price_data) try: self.last_volume = volume_data[-1] self.last_price = close_data[-1] await self._trigger_evaluation(cryptocurrency, symbol, evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) except IndexError: # candles data history is probably not yet available self.logger.debug(f"Impossible to evaluate, no historical data for {symbol} on {time_frame}") async def kline_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, kline): self.last_volume = kline[commons_enums.PriceIndexes.IND_PRICE_VOL.value] self.last_price = kline[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value] await self._trigger_evaluation(cryptocurrency, symbol, evaluators_util.get_eval_time(kline=kline)) async def _trigger_evaluation(self, cryptocurrency, symbol, time): self.evaluate_volume_fluctuations() if self.something_is_happening and self.eval_note != commons_constants.START_PENDING_EVAL_NOTE: if abs(self.last_notification_eval - self.eval_note) >= self.MIN_TRIGGERING_DELTA: self.last_notification_eval = self.eval_note await self.evaluation_completed(cryptocurrency, symbol, self.available_time_frame, eval_time=time) self.something_is_happening = False else: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE def evaluate_volume_fluctuations(self): volume_trigger = 0 price_trigger = 0 for segment in self.candle_segments: if segment in self.average_volumes and segment in self.average_prices: # check volume fluctuation if self.last_volume > self.VOLUME_HAPPENING_THRESHOLD * self.average_volumes[segment]: volume_trigger += 1 self.something_is_happening = True # check price fluctuation segment_average_price = self.average_prices[segment] if self.last_price > (1 + self.PRICE_HAPPENING_THRESHOLD) * segment_average_price: price_trigger += 1 self.something_is_happening = True elif self.last_price < (1 - self.PRICE_HAPPENING_THRESHOLD) * segment_average_price: price_trigger -= 1 self.something_is_happening = True if self.candle_segments: average_volume_trigger = min(1, volume_trigger / len(self.candle_segments) + 0.2) average_price_trigger = price_trigger / len(self.candle_segments) if average_price_trigger < 0: # math.cos(1-x) between 0 and 1 starts around 0.5 and smoothly goes up to 1 self.eval_note = -1 * math.cos(1 - (-1 * average_price_trigger * average_volume_trigger)) elif average_price_trigger > 0: self.eval_note = math.cos(1 - average_price_trigger * average_volume_trigger) else: # no price info => high volume but no price move, can't say anything self.something_is_happening = False else: self.something_is_happening = False async def start(self, bot_id: str) -> bool: """ Subscribe to Kline and OHLCV notification :return: bool """ try: import octobot_trading.exchange_channel as exchange_channels import octobot_trading.api as trading_api exchange_id = trading_api.get_exchange_id_from_matrix_id(self.exchange_name, self.matrix_id) await exchange_channels.get_chan(channels_name.OctoBotTradingChannelsName.OHLCV_CHANNEL.value, exchange_id).new_consumer( callback=self.ohlcv_callback, symbol=self.symbol, time_frame=self.available_time_frame, priority_level=self.priority_level) await exchange_channels.get_chan(channels_name.OctoBotTradingChannelsName.KLINE_CHANNEL.value, exchange_id).new_consumer( callback=self.kline_callback, symbol=self.symbol, time_frame=self.available_time_frame, priority_level=self.priority_level) return True except ImportError: self.logger.error("Can't connect to trading channels") return False def set_default_config(self): super().set_default_config() self.specific_config[commons_constants.CONFIG_TIME_FRAME] = "1m" @classmethod def get_is_symbol_wildcard(cls) -> bool: """ :return: True if the evaluator is not symbol dependant else False """ return False class InstantMAEvaluator(evaluators.RealTimeEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.last_candle_data = {} self.last_moving_average_values = {} self.period = 6 self.time_frame = None self.price_threshold = 0.05 def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.time_frame = self.time_frame or \ self.UI.user_input(commons_constants.CONFIG_TIME_FRAME, commons_enums.UserInputTypes.OPTIONS, commons_enums.TimeFrames.ONE_MINUTE.value, inputs, options=[tf.value for tf in commons_enums.TimeFrames], title="Time frame: The time frame to observe in order to spot changes.") self.period = self.UI.user_input("period", commons_enums.UserInputTypes.INT, 6, inputs, min_val=1, title="Period: the EMA period length to use.") self.price_threshold = self.UI.user_input( "threshold", commons_enums.UserInputTypes.FLOAT, self.price_threshold * 100, inputs, min_val=0, title="Price threshold: price difference in percent from the current moving average value starting " "from which to trigger an evaluation." ) / 100 async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle): self.eval_note = 0 new_data = self.get_symbol_candles(exchange, exchange_id, symbol, time_frame). \ get_symbol_close_candles(20) should_eval = symbol not in self.last_candle_data or \ not self._compare_data(new_data, self.last_candle_data[symbol]) self.last_candle_data[symbol] = new_data if should_eval: if len(self.last_candle_data[symbol]) > self.period: self.last_moving_average_values[symbol] = tulipy.sma(self.last_candle_data[symbol], self.period) await self._evaluate_current_price(self.last_candle_data[symbol][-1], cryptocurrency, symbol, evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) async def kline_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, kline): if symbol in self.last_moving_average_values and len(self.last_moving_average_values[symbol]) > 0: self.eval_note = 0 last_price = kline[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value] if last_price != self.last_candle_data[symbol][-1]: await self._evaluate_current_price(last_price, cryptocurrency, symbol, evaluators_util.get_eval_time(kline=kline)) async def _evaluate_current_price(self, last_price, cryptocurrency, symbol, time): last_ma_value = self.last_moving_average_values[symbol][-1] if last_ma_value == 0: self.eval_note = 0 else: lower_threshold = last_ma_value * (1 - self.price_threshold) upper_threshold = last_ma_value * (1 + self.price_threshold) if lower_threshold < last_price < upper_threshold: self.eval_note = 0 else: current_ratio = last_price / last_ma_value if current_ratio > 1: # last_price > last_ma_value => sell ? => eval_note > 0 if current_ratio >= 2: self.eval_note = 1 else: self.eval_note = current_ratio - 1 elif current_ratio < 1: # last_price < last_ma_value => buy ? => eval_note < 0 self.eval_note = -1 * (1 - current_ratio) else: self.eval_note = 0 await self.evaluation_completed(cryptocurrency, symbol, self.available_time_frame, eval_time=time) async def start(self, bot_id: str) -> bool: """ Subscribe to Kline and OHLCV notification :return: bool """ try: import octobot_trading.exchange_channel as exchange_channels import octobot_trading.api as trading_api exchange_id = trading_api.get_exchange_id_from_matrix_id(self.exchange_name, self.matrix_id) await exchange_channels.get_chan(channels_name.OctoBotTradingChannelsName.OHLCV_CHANNEL.value, exchange_id).new_consumer( callback=self.ohlcv_callback, time_frame=self.available_time_frame, priority_level=self.priority_level) await exchange_channels.get_chan(channels_name.OctoBotTradingChannelsName.KLINE_CHANNEL.value, exchange_id).new_consumer( callback=self.kline_callback, time_frame=self.available_time_frame, priority_level=self.priority_level) return True except ImportError: self.logger.error("Can't connect to trading channels") return False def set_default_config(self): super().set_default_config() self.specific_config[commons_constants.CONFIG_TIME_FRAME] = "1m" @staticmethod def _compare_data(new_data, old_data): try: if new_data[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value][-1] != \ old_data[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value][-1]: return False return True except Exception: return False ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["InstantFluctuationsEvaluator", "InstantMAEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/resources/InstantFluctuationsEvaluator.md ================================================ Triggers when a superior to 1% change of price or a superior to x4 change of volume from recent average happens. The price distance from recent average is defining the strength the evaluation. ================================================ FILE: Evaluator/RealTime/instant_fluctuations_evaluator/resources/InstantMAEvaluator.md ================================================ Uses a [moving average](https://www.investopedia.com/terms/m/movingaverage.asp) computed on close prices to set its evaluation. Will trigger an evaluation when the current close price is beyond the given price threshold applied on the latest moving average value. Triggers on each new candle and price change. ================================================ FILE: Evaluator/Social/forum_evaluator/__init__.py ================================================ from .forum import RedditForumEvaluator ================================================ FILE: Evaluator/Social/forum_evaluator/config/RedditForumEvaluator.json ================================================ { "crypto-currencies": [ { "crypto-currency": "Bitcoin", "subreddits": [ "Bitcoin" ] }, { "crypto-currency": "Ethereum", "subreddits": [ "ethereum" ] }, { "crypto-currency": "NEO", "subreddits": [ "NEO" ] }, { "crypto-currency": "ICON", "subreddits": [ "icon" ] }, { "crypto-currency": "NANO", "subreddits": [ "nanocurrency" ] }, { "crypto-currency": "VeChain", "subreddits": [ "Vechain" ] }, { "crypto-currency": "VeChain Thor", "subreddits": [ "Vechain" ] }, { "crypto-currency": "Substratum", "subreddits": [ "SubstratumNetwork" ] }, { "crypto-currency": "Ethos", "subreddits": [ "ethos_io" ] }, { "crypto-currency": "Ontology", "subreddits": [ "OntologyNetwork" ] }, { "crypto-currency": "Binance Coin", "subreddits": [] } ] } ================================================ FILE: Evaluator/Social/forum_evaluator/forum.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.tentacles_management as tentacles_management import octobot_evaluators.evaluators as evaluators import octobot_services.constants as services_constants import tentacles.Services.Services_feeds as Services_feeds import tentacles.Evaluator.Util as EvaluatorUtil CONFIG_REDDIT = "reddit" CONFIG_REDDIT_SUBREDDITS = "subreddits" CONFIG_REDDIT_ENTRY = "entry" CONFIG_REDDIT_ENTRY_WEIGHT = "entry_weight" # RedditForumEvaluator is used to get an overall state of a market, it will not trigger a trade # (notify its evaluators) but is used to measure hype and trend of a market. class RedditForumEvaluator(evaluators.SocialEvaluator): SERVICE_FEED_CLASS = Services_feeds.RedditServiceFeed if hasattr(Services_feeds, 'RedditServiceFeed') else None def __init__(self, tentacles_setup_config): evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) self.overall_state_analyser = EvaluatorUtil.OverallStateAnalyser() self.count = 0 self.sentiment_analyser = None self.is_self_refreshing = True self.subreddits_by_cryptocurrency = {} def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ cryptocurrencies = [] config_cryptocurrencies = self.UI.user_input( commons_constants.CONFIG_CRYPTO_CURRENCIES, commons_enums.UserInputTypes.OBJECT_ARRAY, cryptocurrencies, inputs, other_schema_values={"minItems": 1, "uniqueItems": True}, item_title="Crypto currency", title="Crypto currencies to watch." ) # init one user input to generate user input schema and default values cryptocurrencies.append(self._init_cryptocurrencies(inputs, "Bitcoin", ["Bitcoin"])) # remove other symbols data to avoid unnecessary entries self.subreddits_by_cryptocurrency = self._get_config_elements(config_cryptocurrencies, CONFIG_REDDIT_SUBREDDITS) self.feed_config[services_constants.CONFIG_REDDIT_SUBREDDITS] = self.subreddits_by_cryptocurrency def _init_cryptocurrencies(self, inputs, cryptocurrency, subreddits): return { commons_constants.CONFIG_CRYPTO_CURRENCY: self.UI.user_input(commons_constants.CONFIG_CRYPTO_CURRENCY, commons_enums.UserInputTypes.TEXT, cryptocurrency, inputs, other_schema_values={"minLength": 2}, parent_input_name=commons_constants.CONFIG_CRYPTO_CURRENCIES, array_indexes=[0], title="Crypto currency name"), CONFIG_REDDIT_SUBREDDITS: self.UI.user_input(CONFIG_REDDIT_SUBREDDITS, commons_enums.UserInputTypes.STRING_ARRAY, subreddits, inputs, other_schema_values={"uniqueItems": True}, parent_input_name=commons_constants.CONFIG_CRYPTO_CURRENCIES, array_indexes=[0], item_title="Subreddit name", title="Subreddits to watch") } @classmethod def get_is_cryptocurrencies_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency dependant else False """ return False @classmethod def get_is_cryptocurrency_name_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency name dependant else False """ return False def _print_entry(self, entry_text, entry_note, count=""): self.logger.debug(f"New reddit entry ! : {entry_note} | {count} : {self.cryptocurrency_name} : " f"Link : {entry_text}") async def _feed_callback(self, data): if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): self.count += 1 entry_note = self._get_sentiment(data[CONFIG_REDDIT_ENTRY]) if entry_note != commons_constants.START_PENDING_EVAL_NOTE: self.overall_state_analyser.add_evaluation(entry_note, data[CONFIG_REDDIT_ENTRY_WEIGHT], False) if data[CONFIG_REDDIT_ENTRY_WEIGHT] > 3: link = f"https://www.reddit.com{data[CONFIG_REDDIT_ENTRY].permalink}" self._print_entry(link, entry_note, str(self.count)) self.eval_note = self.overall_state_analyser.get_overall_state_after_refresh() await self.evaluation_completed(self.cryptocurrency, eval_time=self.get_current_exchange_time()) def _get_sentiment(self, entry): # analysis entry text and gives overall sentiment reddit_entry_min_length = 50 # ignore usless (very short) entries if entry.selftext and len(entry.selftext) >= reddit_entry_min_length: return -1 * self.sentiment_analyser.analyse(entry.selftext) return commons_constants.START_PENDING_EVAL_NOTE def _is_interested_by_this_notification(self, notification_description): # true if the given subreddit is in this cryptocurrency's subreddits configuration try: for subreddit in self.subreddits_by_cryptocurrency[self.cryptocurrency_name]: if subreddit.lower() == notification_description: return True except KeyError: pass return False def _get_config_elements(self, config_cryptocurrencies, key): if config_cryptocurrencies: return { cc[commons_constants.CONFIG_CRYPTO_CURRENCY]: cc[key] for cc in config_cryptocurrencies if cc[commons_constants.CONFIG_CRYPTO_CURRENCY] == self.cryptocurrency_name } return {} async def prepare(self): self.sentiment_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.TextAnalysis)() ================================================ FILE: Evaluator/Social/forum_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["RedditForumEvaluator"], "tentacles-requirements": ["overall_state_analysis", "text_analysis", "reddit_service_feed"] } ================================================ FILE: Evaluator/Social/forum_evaluator/resources/RedditForumEvaluator.md ================================================ First initialises using the recent history of the subreddits in RedditForumEvaluator.json then watches for new posts to update its evaluation. Never triggers strategies re-evaluations, acts as a background evaluator ================================================ FILE: Evaluator/Social/news_evaluator/__init__.py ================================================ from .news import TwitterNewsEvaluator ================================================ FILE: Evaluator/Social/news_evaluator/config/TwitterNewsEvaluator.json ================================================ { "crypto-currencies": [ { "crypto-currency": "Bitcoin", "accounts": [ "BTCFoundation" ], "hashtags": [] }, { "crypto-currency": "Ethereum", "accounts": [ "ethereum", "VitalikButerin" ], "hashtags": [] }, { "crypto-currency": "Neo", "accounts": [ "NEO_Blockchain", "NEOnewstoday", "NEO_council", "neotogas", "NEO_DevCon", "neonexchange", "dahongfei" ], "hashtags": [] }, { "crypto-currency": "ICON", "accounts": [ "helloiconworld" ], "hashtags": [] }, { "crypto-currency": "NANO", "accounts": [ "nanocurrency" ], "hashtags": [] }, { "crypto-currency": "VeChain", "accounts": [ "sunshinelu24", "VechainThorCom", "Vechain1" ], "hashtags": [] }, { "crypto-currency": "VeChain Thor", "accounts": [ "sunshinelu24", "VechainThorCom", "Vechain1" ], "hashtags": [] }, { "crypto-currency": "Substratum", "accounts": [ "SubstratumNet" ], "hashtags": [] }, { "crypto-currency": "Ethos", "accounts": [ "Ethos_io" ], "hashtags": [] }, { "crypto-currency": "Ontology", "accounts": [ "OntologyNetwork" ], "hashtags": [] }, { "crypto-currency": "Binance Coin", "accounts": [], "hashtags": [] } ] } ================================================ FILE: Evaluator/Social/news_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TwitterNewsEvaluator"], "tentacles-requirements": ["text_analysis", "twitter_service_feed"] } ================================================ FILE: Evaluator/Social/news_evaluator/news.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.tentacles_management as tentacles_management import octobot_services.constants as services_constants import octobot_evaluators.evaluators as evaluators from tentacles.Evaluator.Util.text_analysis import TextAnalysis import tentacles.Services.Services_feeds as Services_feeds # disable inheritance to disable tentacle visibility. Disabled as starting from feb 9 2023, API is now paid only # class TwitterNewsEvaluator(evaluators.SocialEvaluator): class TwitterNewsEvaluator: SERVICE_FEED_CLASS = Services_feeds.TwitterServiceFeed if hasattr(Services_feeds, 'TwitterServiceFeed') else None # max time to live for a pulse is 10min _EVAL_MAX_TIME_TO_LIVE = 10 * commons_constants.MINUTE_TO_SECONDS # absolute value above which a notification is triggered _EVAL_NOTIFICATION_THRESHOLD = 0.6 def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.count = 0 self.sentiment_analyser = None self.is_self_refreshing = True self.accounts_by_cryptocurrency = {} self.hashtags_by_cryptocurrency = {} def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ cryptocurrencies = [] config_cryptocurrencies = self.UI.user_input( commons_constants.CONFIG_CRYPTO_CURRENCIES, commons_enums.UserInputTypes.OBJECT_ARRAY, cryptocurrencies, inputs, other_schema_values={"minItems": 1, "uniqueItems": True}, item_title="Crypto currency", title="Crypto currencies to watch." ) # init one user input to generate user input schema and default values cryptocurrencies.append(self._init_cryptocurrencies(inputs, "Bitcoin", ["BTCFoundation"], [])) # remove other symbols data to avoid unnecessary entries self.accounts_by_cryptocurrency = self._get_config_elements(config_cryptocurrencies, services_constants.CONFIG_TWITTERS_ACCOUNTS) self.hashtags_by_cryptocurrency = self._get_config_elements(config_cryptocurrencies, services_constants.CONFIG_TWITTERS_HASHTAGS) self.feed_config[services_constants.CONFIG_TWITTERS_ACCOUNTS] = self.accounts_by_cryptocurrency self.feed_config[services_constants.CONFIG_TWITTERS_HASHTAGS] = self.hashtags_by_cryptocurrency def _init_cryptocurrencies(self, inputs, cryptocurrency, accounts, hashtags): return { commons_constants.CONFIG_CRYPTO_CURRENCY: self.UI.user_input(commons_constants.CONFIG_CRYPTO_CURRENCY, commons_enums.UserInputTypes.TEXT, cryptocurrency, inputs, other_schema_values={"minLength": 2}, parent_input_name=commons_constants.CONFIG_CRYPTO_CURRENCIES, array_indexes=[0], title="Crypto currency name"), services_constants.CONFIG_TWITTERS_ACCOUNTS: self.UI.user_input(services_constants.CONFIG_TWITTERS_ACCOUNTS, commons_enums.UserInputTypes.STRING_ARRAY, accounts, inputs, other_schema_values={"uniqueItems": True}, parent_input_name=commons_constants.CONFIG_CRYPTO_CURRENCIES, array_indexes=[0], item_title="Twitter account name", title="Twitter accounts to watch"), services_constants.CONFIG_TWITTERS_HASHTAGS: self.UI.user_input(services_constants.CONFIG_TWITTERS_HASHTAGS, commons_enums.UserInputTypes.STRING_ARRAY, hashtags, inputs, other_schema_values={"uniqueItems": True}, parent_input_name=commons_constants.CONFIG_CRYPTO_CURRENCIES, array_indexes=[0], item_title="Hashtag", title="Twitter hashtags to watch (without the # character), " "warning: might trigger evaluator for irrelevant tweets.") } @classmethod def get_is_cryptocurrencies_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency dependant else False """ return False @classmethod def get_is_cryptocurrency_name_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency name dependant else False """ return False def _print_tweet(self, tweet_text, tweet_url, note, count=""): self.logger.debug(f"Current note : {note} | {count} : {self.cryptocurrency_name} : Link: {tweet_url} Text : " f"{tweet_text.encode('utf-8', 'ignore')}") async def _feed_callback(self, data): if self._is_interested_by_this_notification(data[services_constants.CONFIG_TWEET_DESCRIPTION]): self.count += 1 note = self._get_tweet_sentiment(data[services_constants.CONFIG_TWEET], data[services_constants.CONFIG_TWEET_DESCRIPTION]) tweet_url = f"https://twitter.com/ProducToken/status/{data['tweet']['id']}" if note != commons_constants.START_PENDING_EVAL_NOTE: self._print_tweet(data[services_constants.CONFIG_TWEET_DESCRIPTION], tweet_url, note, str(self.count)) await self._check_eval_note(note) # only set eval note when something is happening async def _check_eval_note(self, note): if note != commons_constants.START_PENDING_EVAL_NOTE: if abs(note) > self._EVAL_NOTIFICATION_THRESHOLD: self.eval_note = note self.save_evaluation_expiration_time(self._compute_notification_time_to_live(self.eval_note)) await self.evaluation_completed(self.cryptocurrency, eval_time=self.get_current_exchange_time()) @staticmethod def _compute_notification_time_to_live(evaluation): return TwitterNewsEvaluator._EVAL_MAX_TIME_TO_LIVE * abs(evaluation) def _get_tweet_sentiment(self, tweet, tweet_text, is_a_quote=False): try: if is_a_quote: return -1 * self.sentiment_analyser.analyse(tweet_text) else: padding_name = "########" author_screen_name = tweet['user']['screen_name'] if "screen_name" in tweet['user'] \ else padding_name author_name = tweet['user']['name'] if "name" in tweet['user'] else padding_name if author_screen_name in self.accounts_by_cryptocurrency[self.cryptocurrency_name] \ or author_name in self.accounts_by_cryptocurrency[self.cryptocurrency_name]: return -1 * self.sentiment_analyser.analyse(tweet_text) except KeyError: pass # ignore # for the moment (too much of bullshit) return commons_constants.START_PENDING_EVAL_NOTE def _is_interested_by_this_notification(self, notification_description): # true if in twitter accounts try: for account in self.accounts_by_cryptocurrency[self.cryptocurrency_name]: if account.lower() in notification_description: return True except KeyError: return False # false if it's a RT of an unfollowed account if notification_description.startswith("rt"): return False # true if contains symbol if self.cryptocurrency_name.lower() in notification_description: return True # true if in hashtags if self.hashtags_by_cryptocurrency: for hashtags in self.hashtags_by_cryptocurrency[self.cryptocurrency_name]: if hashtags.lower() in notification_description: return True return False def _get_config_elements(self, config_cryptocurrencies, key): if config_cryptocurrencies: return { cc[commons_constants.CONFIG_CRYPTO_CURRENCY]: cc[key] for cc in config_cryptocurrencies if cc[commons_constants.CONFIG_CRYPTO_CURRENCY] == self.cryptocurrency_name } return {} async def prepare(self): self.sentiment_analyser = tentacles_management.get_single_deepest_child_class(TextAnalysis)() ================================================ FILE: Evaluator/Social/news_evaluator/resources/TwitterNewsEvaluator.md ================================================ Triggers when a new tweet appears from a Twitter account in TwitterNewsEvaluator.json. If the evaluation of any given tweet is significant enough, triggers strategies re-evaluation. Otherwise acts as a background evaluator. ================================================ FILE: Evaluator/Social/signal_evaluator/__init__.py ================================================ from .signal import TelegramSignalEvaluator, TelegramChannelSignalEvaluator ================================================ FILE: Evaluator/Social/signal_evaluator/config/TelegramChannelSignalEvaluator.json ================================================ { "telegram-channels": [ { "channel_name": "Test-Channel", "signal_pair": "Pair: (.*)$", "signal_pattern": { "MARKET_BUY": "Side: (BUY)$", "MARKET_SELL": "Side: (SELL)$" } } ] } ================================================ FILE: Evaluator/Social/signal_evaluator/config/TelegramSignalEvaluator.json ================================================ { "telegram-channels": [ "test_telegram_signal_strat" ] } ================================================ FILE: Evaluator/Social/signal_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramSignalEvaluator", "TelegramChannelSignalEvaluator"], "tentacles-requirements": ["telegram_service_feed"] } ================================================ FILE: Evaluator/Social/signal_evaluator/resources/TelegramChannelSignalEvaluator.md ================================================ Evaluator that catch Telegram channel signals. Triggers on a Telegram signal from any channel your personal account joined. Signal parsing is configurable according to the name of the channel. See [OctoBot docs about Telegram API service](https://www.octobot.cloud/en/guides/octobot-interfaces/telegram/telegram-api?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=telegramChannelSignalEvaluator) for more information. ================================================ FILE: Evaluator/Social/signal_evaluator/resources/TelegramSignalEvaluator.md ================================================ Very simple evaluator designed to be an example for an evaluator using Telegram signals. Triggers on a Telegram signal from any group or channel listed in this evaluator configuration in which your Telegram bot is invited. Signal format for this implementation is: **SYMBOL[evaluation]**. Example: **BTC/USDT[-0.45]**. SYMBOL has to be in current watched symbols (in configuration) and evaluation must be between -1 and 1. Remember that OctoBot can only see messages from a chat/group where its Telegram bot (in OctoBot configuration) has been invited. Keep also in mind that you need to disable the privacy mode of your Telegram bot to allow it to see group messages. See [OctoBot docs about Telegram interface](https://www.octobot.cloud/en/guides/octobot-interfaces/telegram?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=telegramSignalEvaluator) for more information. ================================================ FILE: Evaluator/Social/signal_evaluator/signal.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import re import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_services.constants as services_constants import octobot_evaluators.evaluators as evaluators import tentacles.Services.Services_feeds as Services_feeds class TelegramSignalEvaluator(evaluators.SocialEvaluator): SERVICE_FEED_CLASS = Services_feeds.TelegramServiceFeed if hasattr(Services_feeds, 'TelegramServiceFeed') else None def init_user_inputs(self, inputs: dict) -> None: channels_config = self.UI.user_input(services_constants.CONFIG_TELEGRAM_CHANNEL, commons_enums.UserInputTypes.STRING_ARRAY, [], inputs, item_title="Channel name", title="Name of the watched channels") self.feed_config[services_constants.CONFIG_TELEGRAM_CHANNEL] = channels_config async def _feed_callback(self, data): if self._is_interested_by_this_notification(data[services_constants.CONFIG_GROUP_MESSAGE_DESCRIPTION]): await self.analyse_notification(data) await self.evaluation_completed(self.cryptocurrency, self.symbol, eval_time=self.get_current_exchange_time()) else: self.logger.debug(f"Ignored telegram feed: \"{self.symbol.lower()}\" pattern not found in " f"\"{data[services_constants.CONFIG_GROUP_MESSAGE_DESCRIPTION].lower()}\"") # return true if the given notification is relevant for this client def _is_interested_by_this_notification(self, notification_description): if self.symbol: return self.symbol.lower() in notification_description.lower() else: return True async def analyse_notification(self, notification): notification_test = notification[services_constants.CONFIG_GROUP_MESSAGE_DESCRIPTION] self.eval_note = commons_constants.START_PENDING_EVAL_NOTE start_eval_chars = "[" end_eval_chars = "]" if start_eval_chars in notification_test and end_eval_chars in notification_test: try: split_test = notification_test.split(start_eval_chars) notification_eval = split_test[1].split(end_eval_chars)[0] potential_note = float(notification_eval) if -1 <= potential_note <= 1: self.eval_note = potential_note else: self.logger.error(f"Impossible to use notification evaluation: {notification_eval}: " f"evaluation should be between -1 and 1.") except Exception as e: self.logger.error(f"Impossible to parse notification {notification_test}: {e}. Please refer to this " f"evaluator documentation to check the notification pattern.") else: self.logger.error(f"Impossible to parse notification {notification_test}. Please refer to this evaluator " f"documentation to check the notification pattern.") @classmethod def get_is_cryptocurrencies_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency dependant else False """ return False @classmethod def get_is_cryptocurrency_name_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency name dependant else False """ return False @classmethod def get_is_symbol_wildcard(cls) -> bool: """ :return: True if the evaluator is not symbol dependant else False """ return False def _get_tentacle_registration_topic(self, all_symbols_by_crypto_currencies, time_frames, real_time_time_frames): currencies = [self.cryptocurrency] symbols = [self.symbol] to_handle_time_frames = [self.time_frame] if self.get_is_cryptocurrencies_wildcard(): currencies = all_symbols_by_crypto_currencies.keys() if self.get_is_symbol_wildcard(): symbols = [] for currency_symbols in all_symbols_by_crypto_currencies.values(): symbols += currency_symbols # by default no time frame registration for social evaluators return currencies, symbols, to_handle_time_frames class TelegramChannelSignalEvaluator(evaluators.SocialEvaluator): SERVICE_FEED_CLASS = Services_feeds.TelegramApiServiceFeed if hasattr(Services_feeds, 'TelegramApiServiceFeed') else None SIGNAL_PATTERN_KEY = "signal_pattern" SIGNAL_PATTERN_MARKET_BUY_KEY = "MARKET_BUY" SIGNAL_PATTERN_MARKET_SELL_KEY = "MARKET_SELL" SIGNAL_PAIR_KEY = "signal_pair" SIGNAL_CHANNEL_NAME_KEY = "channel_name" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.channels_config_by_channel_name = {} def init_user_inputs(self, inputs: dict) -> None: channels = [] config_channels = self.UI.user_input(services_constants.CONFIG_TELEGRAM_CHANNEL, commons_enums.UserInputTypes.OBJECT_ARRAY, channels, inputs, item_title="Channel", other_schema_values={"minItems": 1, "uniqueItems": True}, title="Channels to watch") channels.append(self._init_channel_config(inputs, "Test-Channel", "Pair: (.*)$", "Side: (BUY)$", "Side: (SELL)$")) self.channels_config_by_channel_name = { channel[self.SIGNAL_CHANNEL_NAME_KEY]: channel for channel in config_channels } self.feed_config[services_constants.CONFIG_TELEGRAM_CHANNEL] = list(self.channels_config_by_channel_name) def _init_channel_config(self, inputs, channel_name, signal_pair, buy_regex, sell_regex): return { self.SIGNAL_CHANNEL_NAME_KEY: self.UI.user_input( self.SIGNAL_CHANNEL_NAME_KEY, commons_enums.UserInputTypes.TEXT, channel_name, inputs, parent_input_name=services_constants.CONFIG_TELEGRAM_CHANNEL, array_indexes=[0], title="Channel name"), self.SIGNAL_PAIR_KEY: self.UI.user_input( self.SIGNAL_PAIR_KEY, commons_enums.UserInputTypes.TEXT, signal_pair, inputs, parent_input_name=services_constants.CONFIG_TELEGRAM_CHANNEL, array_indexes=[0], title="Trading pair regex, ex: Pair: (.*)$"), self.SIGNAL_PATTERN_KEY: self.UI.user_input( self.SIGNAL_PATTERN_KEY, commons_enums.UserInputTypes.OBJECT, self._init_pattern_config(inputs, buy_regex, sell_regex), inputs, parent_input_name=services_constants.CONFIG_TELEGRAM_CHANNEL, array_indexes=[0], title="Signal patterns"), } def _init_pattern_config(self, inputs, buy_regex, sell_regex): return { self.SIGNAL_PATTERN_MARKET_BUY_KEY: self.UI.user_input( self.SIGNAL_PATTERN_MARKET_BUY_KEY, commons_enums.UserInputTypes.TEXT, buy_regex, inputs, parent_input_name=self.SIGNAL_PATTERN_KEY, array_indexes=[0], title="Market buy signal regex, ex: Side: (BUY)$"), self.SIGNAL_PATTERN_MARKET_SELL_KEY: self.UI.user_input( self.SIGNAL_PATTERN_MARKET_SELL_KEY, commons_enums.UserInputTypes.TEXT, sell_regex, inputs, parent_input_name=self.SIGNAL_PATTERN_KEY, array_indexes=[0], title="Market sell signal regex, ex: Side: (SELL)$"), } async def _feed_callback(self, data): if not data: return is_from_channel = data.get(services_constants.CONFIG_IS_CHANNEL_MESSAGE, False) if is_from_channel: sender = data.get(services_constants.CONFIG_MESSAGE_SENDER, "") if sender in self.channels_config_by_channel_name: try: message = data.get(services_constants.CONFIG_MESSAGE_CONTENT, "") channel_data = self.channels_config_by_channel_name[sender] is_buy_market_signal = self._get_signal_message( channel_data[self.SIGNAL_PATTERN_KEY][self.SIGNAL_PATTERN_MARKET_BUY_KEY], message) is_sell_market_signal = self._get_signal_message( channel_data[self.SIGNAL_PATTERN_KEY][self.SIGNAL_PATTERN_MARKET_SELL_KEY], message) pair = self._get_signal_message(channel_data[self.SIGNAL_PAIR_KEY], message) if (is_buy_market_signal or is_sell_market_signal) and pair is not None: self.eval_note = -1 if is_buy_market_signal else 1 await self.evaluation_completed(symbol=pair.strip(), eval_time=self.get_current_exchange_time()) else: self.logger.warning(f"Unable to parse message from {sender} : {message}") except KeyError: self.logger.warning(f"Unable to parse message from {sender}") else: self.logger.debug(f"Ignored message : from an unsupported channel ({sender})") else: self.logger.debug("Ignored message : not a channel message") def _get_signal_message(self, expected_pattern, message): try: match = re.search(expected_pattern, message) return match.group(1) except AttributeError: self.logger.debug(f"Ignored message : not matching channel pattern ({message})") return None ================================================ FILE: Evaluator/Social/signal_evaluator/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Evaluator/Social/signal_evaluator/tests/test_telegram_channel_signal_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.constants as commons_constants import octobot_commons.logging as logging import octobot_services.constants as services_constants import tentacles.Evaluator.Social as Social import tests.test_utils.config as test_utils_config # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def _trigger_callback_with_data_and_assert_note(evaluator: Social.TelegramChannelSignalEvaluator, data=None, note=commons_constants.START_PENDING_EVAL_NOTE): await evaluator._feed_callback(data) assert evaluator.eval_note == note evaluator.eval_note = commons_constants.START_PENDING_EVAL_NOTE def _create_evaluator_with_supported_channel_signals(): evaluator = Social.TelegramChannelSignalEvaluator(test_utils_config.load_test_tentacles_config()) evaluator.logger = logging.get_logger(evaluator.get_name()) evaluator.specific_config = { "telegram-channels": [ { "channel_name": "TEST-CHAN-1", "signal_pattern": { "MARKET_BUY": "Side: (BUY)", "MARKET_SELL": "Side: (SELL)" }, "signal_pair": "Pair: (.*)" }, { "channel_name": "TEST-CHAN-2", "signal_pattern": { "MARKET_BUY": ".* : (-1)$", "MARKET_SELL": ".* : (1)$" }, "signal_pair": "(.*):" } ] } evaluator.init_user_inputs({}) evaluator.eval_note = commons_constants.START_PENDING_EVAL_NOTE return evaluator async def test_without_data(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator) async def test_with_empty_data(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={}) async def test_incorrect_signal_without_sender_without_channel_message(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: False, services_constants.CONFIG_MESSAGE_SENDER: "", services_constants.CONFIG_MESSAGE_CONTENT: "", }) async def test_incorrect_signal_without_sender_with_channel_message(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "", services_constants.CONFIG_MESSAGE_CONTENT: "", }) async def test_incorrect_signal_chan1_without_content(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-1", services_constants.CONFIG_MESSAGE_CONTENT: "", }) async def test_incorrect_signal_chan1_without_coin(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-1", services_constants.CONFIG_MESSAGE_CONTENT: """ Order Id: 1631033831358699 Pair: Side: Price: 12.909 """, }) async def test_incorrect_signal_chan1_without_separator(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-1", services_constants.CONFIG_MESSAGE_CONTENT: """ Order Id: 1631033831358699 Pair QTUMUSDT Side: BUY Price: 12.909 """, }) async def test_correct_signal_chan1_with_not_channel_message(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: False, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-1", services_constants.CONFIG_MESSAGE_CONTENT: """ Order Id: 1631033831358699 Pair: QTUMUSDT Side: BUY Price: 12.909 """, }) async def test_correct_signal_chan1_with_chan2(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-2", services_constants.CONFIG_MESSAGE_CONTENT: """ Order Id: 1631033831358699 Pair: QTUMUSDT Side: BUY Price: 12.909 """, }) async def test_correct_signal_chan1(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-1", services_constants.CONFIG_MESSAGE_CONTENT: """ Order Id: 1631033831358699 Pair: QTUMUSDT Side: BUY Price: 12.909 """, }, note=-1) async def test_correct_signal_chan2_but_with_chan1(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-1", services_constants.CONFIG_MESSAGE_CONTENT: "BTC/USDT : 1", }) async def test_correct_signal_chan2(): evaluator = _create_evaluator_with_supported_channel_signals() await _trigger_callback_with_data_and_assert_note(evaluator, data={ services_constants.CONFIG_IS_CHANNEL_MESSAGE: True, services_constants.CONFIG_MESSAGE_SENDER: "TEST-CHAN-2", services_constants.CONFIG_MESSAGE_CONTENT: "BTC/USDT : -1", }, note=-1) ================================================ FILE: Evaluator/Social/trends_evaluator/__init__.py ================================================ from .trends import GoogleTrendsEvaluator ================================================ FILE: Evaluator/Social/trends_evaluator/config/GoogleTrendsEvaluator.json ================================================ { "refresh_rate_seconds" : 86400, "relevant_history_months" : 3 } ================================================ FILE: Evaluator/Social/trends_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GoogleTrendsEvaluator"], "tentacles-requirements": ["statistics_analysis", "google_service_feed"] } ================================================ FILE: Evaluator/Social/trends_evaluator/resources/GoogleTrendsEvaluator.md ================================================ Analyses the popularity of the given currencies using their names. Data are provided by [Google's trends service](https://trends.google.com/trends/?geo=US). Due to Google trends poor refresh rate, this evaluation should be considered for large time frames only. ================================================ FILE: Evaluator/Social/trends_evaluator/trends.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.tentacles_management as tentacles_management import octobot_evaluators.evaluators as evaluators import octobot_services.constants as services_constants import tentacles.Evaluator.Util as EvaluatorUtil import tentacles.Services.Services_feeds as Services_feeds class GoogleTrendsEvaluator(evaluators.SocialEvaluator): SERVICE_FEED_CLASS = Services_feeds.GoogleServiceFeed if hasattr(Services_feeds, 'GoogleServiceFeed') else None def __init__(self, tentacles_setup_config): evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) self.stats_analyser = None self.refresh_rate_seconds = 86400 self.relevant_history_months = 3 def init_user_inputs(self, inputs: dict) -> None: self.refresh_rate_seconds = self.refresh_rate_seconds or \ self.UI.user_input(commons_constants.CONFIG_REFRESH_RATE, commons_enums.UserInputTypes.INT, self.refresh_rate_seconds, inputs, min_val=1, title="Seconds between each re-evaluation (do not set too low because google has a low " "monthly rate limit).") self.relevant_history_months = self.UI.user_input(services_constants.CONFIG_TREND_HISTORY_TIME, commons_enums.UserInputTypes.INT, self.relevant_history_months, inputs, min_val=3, max_val=3, title="Number of months to look into to compute the trend " "evaluation (for now works only with 3).") self.feed_config[services_constants.CONFIG_TREND_TOPICS] = self._build_trend_topics() @classmethod def get_is_cryptocurrencies_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency dependant else False """ return False @classmethod def get_is_cryptocurrency_name_wildcard(cls) -> bool: """ :return: True if the evaluator is not cryptocurrency name dependant else False """ return False async def _feed_callback(self, data): if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): trend = numpy.array([d["data"] for d in data[services_constants.CONFIG_TREND]]) # compute bollinger bands self.eval_note = self.stats_analyser.analyse_recent_trend_changes(trend, numpy.sqrt) await self.evaluation_completed(self.cryptocurrency, eval_time=self.get_current_exchange_time()) def _is_interested_by_this_notification(self, notification_description): return self.cryptocurrency_name in notification_description def _build_trend_topics(self): trend_time_frame = f"today {self.relevant_history_months}-m" return [ Services_feeds.TrendTopic(self.refresh_rate_seconds, [self.cryptocurrency_name], time_frame=trend_time_frame) ] async def prepare(self): self.stats_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.StatisticAnalysis)() ================================================ FILE: Evaluator/Strategies/blank_strategy_evaluator/__init__.py ================================================ from .blank_strategy import BlankStrategyEvaluator ================================================ FILE: Evaluator/Strategies/blank_strategy_evaluator/blank_strategy.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as common_constants import octobot_commons.enums as common_enums import octobot_evaluators.evaluators as evaluators import octobot_evaluators.enums as enums class BlankStrategyEvaluator(evaluators.StrategyEvaluator): def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ super().init_user_inputs(inputs) self.UI.user_input(common_constants.CONFIG_TENTACLES_REQUIRED_CANDLES_COUNT, common_enums.UserInputTypes.INT, 200, inputs, min_val=1, title="Initialization candles count: the number of historical candles to fetch from " "exchanges when OctoBot is starting.") def get_full_cycle_evaluator_types(self) -> tuple: # returns a tuple as it is faster to create than a list return enums.EvaluatorMatrixTypes.TA.value, enums.EvaluatorMatrixTypes.SCRIPTED.value async def matrix_callback(self, matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol, time_frame): self.eval_note = eval_note await self.strategy_completed(cryptocurrency, symbol, time_frame=time_frame) ================================================ FILE: Evaluator/Strategies/blank_strategy_evaluator/config/BlankStrategyEvaluator.json ================================================ { "required_time_frames" : ["1h"], "required_evaluators" : ["*"], "required_candles_count" : 200, "default_config" : ["ScriptedEvaluator"] } ================================================ FILE: Evaluator/Strategies/blank_strategy_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BlankStrategyEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Strategies/blank_strategy_evaluator/resources/BlankStrategyEvaluator.md ================================================ BlankStrategyEvaluator is forwarding evaluator values to the trading mode. ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/__init__.py ================================================ from .dip_analyser_strategy import DipAnalyserStrategyEvaluator ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/config/DipAnalyserStrategyEvaluator.json ================================================ { "default_config": [ "KlingerOscillatorReversalConfirmationMomentumEvaluator", "RSIWeightMomentumEvaluator" ], "required_evaluators": [ "InstantFluctuationsEvaluator", "KlingerOscillatorReversalConfirmationMomentumEvaluator", "RSIWeightMomentumEvaluator" ], "required_time_frames": [ "4h" ] } ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/dip_analyser_strategy.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_evaluators.api.matrix as evaluators_api import octobot_evaluators.evaluators.channel as evaluator_channel import octobot_evaluators.constants as evaluator_constants import octobot_evaluators.matrix as matrix import octobot_evaluators.enums as evaluators_enums import octobot_evaluators.evaluators as evaluators import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.api as trading_api import tentacles.Evaluator.TA as TA class DipAnalyserStrategyEvaluator(evaluators.StrategyEvaluator): REVERSAL_CONFIRMATION_CLASS_NAME = TA.KlingerOscillatorReversalConfirmationMomentumEvaluator.get_name() REVERSAL_WEIGHT_CLASS_NAME = TA.RSIWeightMomentumEvaluator.get_name() @staticmethod def get_eval_type(): return typing.Dict[str, int] def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.evaluation_time_frame = None def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.evaluation_time_frame = self.evaluation_time_frame or commons_enums.TimeFrames( self.UI.user_input( evaluator_constants.STRATEGIES_REQUIRED_TIME_FRAME, commons_enums.UserInputTypes.MULTIPLE_OPTIONS, [commons_enums.TimeFrames.ONE_HOUR.value], inputs, options=[tf.value for tf in commons_enums.TimeFrames], title="Analysed time frame: only the first one will be considered for DipAnalyserStrategyEvaluator." )[0] ).value async def matrix_callback(self, matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol, time_frame): if evaluator_type == evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value: # trigger re-evaluation exchange_id = trading_api.get_exchange_id_from_matrix_id(exchange_name, matrix_id) await evaluator_channel.trigger_technical_evaluators_re_evaluation_with_updated_data(matrix_id, evaluator_name, evaluator_type, exchange_name, cryptocurrency, symbol, exchange_id, self.strategy_time_frames) # do not continue this evaluation return elif evaluator_type == evaluators_enums.EvaluatorMatrixTypes.TA.value: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE TA_evaluations = matrix.get_evaluations_by_evaluator(matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.TA.value, cryptocurrency, symbol, self.evaluation_time_frame, allowed_values=[ commons_constants.START_PENDING_EVAL_NOTE]) try: if evaluators_api.get_value(TA_evaluations[self.REVERSAL_CONFIRMATION_CLASS_NAME]): self.eval_note = evaluators_api.get_value(TA_evaluations[self.REVERSAL_WEIGHT_CLASS_NAME]) await self.strategy_completed(cryptocurrency, symbol) except KeyError as e: self.logger.error(f"Missing required evaluator: {e}") ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["DipAnalyserStrategyEvaluator"], "tentacles-requirements": ["momentum_evaluator.py"] } ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/resources/DipAnalyserStrategyEvaluator.md ================================================ DipAnalyserStrategyEvaluator is a strategy analysing market dips using [RSI](https://www.investopedia.com/terms/r/rsi.asp) averages. According to the level of the RSI, a buy signal can be generated. This signal has a weight that corresponds to a higher or lower intensity of the RSI evaluation. This strategy also uses the [Klinger oscillator](https://www.investopedia.com/terms/k/klingeroscillator.asp) to identify reversals and create buy signals. A buy signal is generated when the RSI component is signaling an opportunity and the Klinger part is confirming a reversal situation. This strategy is updated at the end of each candle on the watched time frame. It is also possible to make it trigger automatically using a real-time evaluator. Using a real time evaluator that signals sudden market changes like the InstantFluctuationsEvaluator will make DipAnalyserStrategyEvaluator also wake up on such events. DipAnalyserStrategyEvaluator focuses on one time frame only and works best on larger time frames such as 4h and more. ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Evaluator/Strategies/dip_analyser_strategy_evaluator/tests/test_dip_analyser_strategy_evaluator.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import decimal import tests.functional_tests.strategy_evaluators_tests.abstract_strategy_test as abstract_strategy_test import tentacles.Evaluator.Strategies as Strategies import tentacles.Trading.Mode as Mode # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest.fixture def strategy_tester(): strategy_tester_instance = DipAnalyserStrategiesEvaluatorTest() strategy_tester_instance.initialize(Strategies.DipAnalyserStrategyEvaluator, Mode.DipAnalyserTradingMode) return strategy_tester_instance class DipAnalyserStrategiesEvaluatorTest(abstract_strategy_test.AbstractStrategyTest): """ About using this test framework: To be called by pytest, tests have to be called manually since the cythonized version of AbstractStrategyTest creates an __init__() which prevents the default pytest tests collect process """ # Careful with results here, unlike other strategy tests, this one uses only the 4h timeframe, therefore results # are not comparable with regular 1h timeframes strategy tests # Cannot use bittrex data since they are not providing 4h timeframe data # test_full_mixed_strategies_evaluator.py with only 4h timeframe results are provided for comparison: # format: results: (bot profitability, market average profitability) async def test_default_run(self): # market: -49.25407390406244 await self.run_test_default_run(decimal.Decimal(str(-24.612))) async def test_slow_downtrend(self): # market: -49.25407390406244 # market: -47.50593824228029 await self.run_test_slow_downtrend(decimal.Decimal(str(-24.612)), decimal.Decimal(str(-33.601)), None, None, skip_extended=True) async def test_sharp_downtrend(self): # market: -34.67997135795625 await self.run_test_sharp_downtrend(decimal.Decimal(str(-21.634)), None, skip_extended=True) async def test_flat_markets(self): # market: -38.07647740440325 # market: -53.87077652637819 await self.run_test_flat_markets(decimal.Decimal(str(-20.577)), decimal.Decimal(str(-32.756)), None, None, skip_extended=True) async def test_slow_uptrend(self): # market: 11.32644122514472 # market: -36.64596273291926 await self.run_test_slow_uptrend(decimal.Decimal(str(11.326)), decimal.Decimal(str(-14.248))) async def test_sharp_uptrend(self): # market: -17.047906776003458 # market: -18.25837965302341 await self.run_test_sharp_uptrend(decimal.Decimal(str(3.607)), decimal.Decimal(str(10.956))) async def test_up_then_down(self): await self.run_test_up_then_down(None, skip_extended=True) async def test_default_run(strategy_tester): await strategy_tester.test_default_run() async def test_slow_downtrend(strategy_tester): await strategy_tester.test_slow_downtrend() async def test_sharp_downtrend(strategy_tester): await strategy_tester.test_sharp_downtrend() async def test_flat_markets(strategy_tester): await strategy_tester.test_flat_markets() async def test_slow_uptrend(strategy_tester): await strategy_tester.test_slow_uptrend() async def test_sharp_uptrend(strategy_tester): await strategy_tester.test_sharp_uptrend() async def test_up_then_down(strategy_tester): await strategy_tester.test_up_then_down() ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/__init__.py ================================================ from .mixed_strategies import SimpleStrategyEvaluator, TechnicalAnalysisStrategyEvaluator ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/config/SimpleStrategyEvaluator.json ================================================ { "default_config": [ "DoubleMovingAverageTrendEvaluator", "RSIMomentumEvaluator" ], "required_evaluators": [ "*" ], "required_time_frames": [ "1h", "4h", "1d" ], "required_candles_count": 1000, "social_evaluators_notification_timeout": 3600, "re_evaluate_TA_when_social_or_realtime_notification": true, "background_social_evaluators": [ "RedditForumEvaluator" ] } ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/config/TechnicalAnalysisStrategyEvaluator.json ================================================ { "compatible_evaluator_types": [ "TA", "REAL_TIME" ], "default_config": [ "DoubleMovingAverageTrendEvaluator", "RSIMomentumEvaluator" ], "required_evaluators": [ "*" ], "required_time_frames": [ "30m", "1h", "2h", "4h", "1d" ], "time_frames_to_weight": [ { "time_frame": "30m", "weight": 30 }, { "time_frame": "1h", "weight": 50 }, { "time_frame": "2h", "weight": 50 }, { "time_frame": "4h", "weight": 50 }, { "time_frame": "1d", "weight": 30 } ] } ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["SimpleStrategyEvaluator", "TechnicalAnalysisStrategyEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/mixed_strategies.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.evaluators_util as evaluators_util import octobot_commons.time_frame_manager as time_frame_manager import octobot_evaluators.api as evaluators_api import octobot_evaluators.evaluators.channel as evaluators_channel import octobot_evaluators.matrix as matrix import octobot_evaluators.enums as evaluators_enums import octobot_evaluators.constants as evaluators_constants import octobot_evaluators.errors as errors import octobot_evaluators.evaluators as evaluators import octobot_tentacles_manager.api.configurator as tentacles_manager_api import octobot_tentacles_manager.configuration as tm_configuration import octobot_trading.api as trading_api class SimpleStrategyEvaluator(evaluators.StrategyEvaluator): SOCIAL_EVALUATORS_NOTIFICATION_TIMEOUT_KEY = "social_evaluators_notification_timeout" RE_EVAL_TA_ON_RT_OR_SOCIAL = "re_evaluate_TA_when_social_or_realtime_notification" BACKGROUND_SOCIAL_EVALUATORS = "background_social_evaluators" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.re_evaluation_triggering_eval_types = [evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value, evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value] self.social_evaluators_default_timeout = None self.re_evaluate_TA_when_social_or_realtime_notification = True self.background_social_evaluators = [] def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ super().init_user_inputs(inputs) default_config = self.get_default_config() self.UI.user_input(commons_constants.CONFIG_TENTACLES_REQUIRED_CANDLES_COUNT, commons_enums.UserInputTypes.INT, default_config[commons_constants.CONFIG_TENTACLES_REQUIRED_CANDLES_COUNT], inputs, min_val=1, title="Initialization candles count: the number of historical candles to fetch from " "exchanges when OctoBot is starting.") self.social_evaluators_default_timeout = \ self.UI.user_input(self.SOCIAL_EVALUATORS_NOTIFICATION_TIMEOUT_KEY, commons_enums.UserInputTypes.INT, default_config[self.SOCIAL_EVALUATORS_NOTIFICATION_TIMEOUT_KEY], inputs, min_val=0, title="Number of seconds to consider a social evaluation valid from the moment it " "appears on OctoBot. Example: a tweet evaluation.") self.re_evaluate_TA_when_social_or_realtime_notification = \ self.UI.user_input(self.RE_EVAL_TA_ON_RT_OR_SOCIAL, commons_enums.UserInputTypes.BOOLEAN, default_config[self.RE_EVAL_TA_ON_RT_OR_SOCIAL], inputs, title="Recompute technical evaluators on real-time evaluator signal: " "When activated, technical evaluators will be asked to recompute their evaluation " "based on the current in-construction candle " "for each new evaluation appearing on social or " "real-time evaluators. After such an event, this strategy will finalize its " "evaluation only once this updated technical analyses will be completed. " "If deactivated, social and real-time evaluations will be taken into account " "alongside technical analysis results of the last closed candle.") self.background_social_evaluators = \ self.UI.user_input(self.BACKGROUND_SOCIAL_EVALUATORS, commons_enums.UserInputTypes.MULTIPLE_OPTIONS, default_config[self.BACKGROUND_SOCIAL_EVALUATORS], inputs, other_schema_values={"minItems": 0, "uniqueItems": True}, options=["RedditForumEvaluator", "TwitterNewsEvaluator", "TelegramSignalEvaluator", "GoogleTrendsEvaluator"], title="Social evaluator to consider as background evaluators: they won't trigger technical " "evaluators re-evaluation when updated. Avoiding unnecessary updates increases " "performances.") @classmethod def get_default_config(cls, time_frames: typing.Optional[list[str]] = None) -> dict: return { evaluators_constants.STRATEGIES_REQUIRED_TIME_FRAME: ( time_frames or [commons_enums.TimeFrames.ONE_HOUR.value] ), commons_constants.CONFIG_TENTACLES_REQUIRED_CANDLES_COUNT: 500, cls.SOCIAL_EVALUATORS_NOTIFICATION_TIMEOUT_KEY: 1 * commons_constants.HOURS_TO_SECONDS, cls.RE_EVAL_TA_ON_RT_OR_SOCIAL: True, cls.BACKGROUND_SOCIAL_EVALUATORS: [], } async def matrix_callback(self, matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol, time_frame): if symbol is None and cryptocurrency is not None and evaluator_type == evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value: # social evaluators can be cryptocurrency related but not symbol related, wakeup every symbol for available_symbol in matrix.get_available_symbols(matrix_id, exchange_name, cryptocurrency): await self._trigger_evaluation(matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, available_symbol) return else: await self._trigger_evaluation(matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol) async def _trigger_evaluation(self, matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol): # ensure only start evaluations when technical evaluators have been initialized try: TA_by_timeframe = { available_time_frame: matrix.get_evaluations_by_evaluator( matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.TA.value, cryptocurrency, symbol, available_time_frame.value, allow_missing=False, allowed_values=[commons_constants.START_PENDING_EVAL_NOTE]) for available_time_frame in self.strategy_time_frames } # social evaluators by symbol social_evaluations_by_evaluator = matrix.get_evaluations_by_evaluator(matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value, cryptocurrency, symbol) # social evaluators by crypto currency social_evaluations_by_evaluator.update(matrix.get_evaluations_by_evaluator(matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value, cryptocurrency)) available_rt_time_frames = self.get_available_time_frames(matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value, cryptocurrency, symbol) RT_evaluations_by_time_frame = { available_time_frame: matrix.get_evaluations_by_evaluator( matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value, cryptocurrency, symbol, available_time_frame) for available_time_frame in available_rt_time_frames } if self.re_evaluate_TA_when_social_or_realtime_notification \ and any(value for value in TA_by_timeframe.values()) \ and evaluator_type != evaluators_enums.EvaluatorMatrixTypes.TA.value \ and evaluator_type in self.re_evaluation_triggering_eval_types \ and evaluator_name not in self.background_social_evaluators: if evaluators_util.check_valid_eval_note(eval_note, eval_type=eval_note_type, expected_eval_type=evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE): # trigger re-evaluation exchange_id = trading_api.get_exchange_id_from_matrix_id(exchange_name, matrix_id) await evaluators_channel.trigger_technical_evaluators_re_evaluation_with_updated_data(matrix_id, evaluator_name, evaluator_type, exchange_name, cryptocurrency, symbol, exchange_id, self.strategy_time_frames) # do not continue this evaluation return counter = 0 total_evaluation = 0 for eval_by_rt in RT_evaluations_by_time_frame.values(): for evaluation in eval_by_rt.values(): eval_value = evaluators_api.get_value(evaluation) if evaluators_util.check_valid_eval_note(eval_value, eval_type=evaluators_api.get_type(evaluation), expected_eval_type=evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE): total_evaluation += eval_value counter += 1 for eval_by_ta in TA_by_timeframe.values(): for evaluation in eval_by_ta.values(): eval_value = evaluators_api.get_value(evaluation) if evaluators_util.check_valid_eval_note(eval_value, eval_type=evaluators_api.get_type(evaluation), expected_eval_type=evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE): total_evaluation += eval_value counter += 1 if social_evaluations_by_evaluator: exchange_manager = trading_api.get_exchange_manager_from_exchange_name_and_id( exchange_name, trading_api.get_exchange_id_from_matrix_id(exchange_name, self.matrix_id) ) current_time = trading_api.get_exchange_current_time(exchange_manager) for evaluation in social_evaluations_by_evaluator.values(): eval_value = evaluators_api.get_value(evaluation) if evaluators_util.check_valid_eval_note(eval_value, eval_type=evaluators_api.get_type(evaluation), expected_eval_type=evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE, eval_time=evaluators_api.get_time(evaluation), expiry_delay=self.social_evaluators_default_timeout, current_time=current_time): total_evaluation += eval_value counter += 1 if counter > 0: self.eval_note = total_evaluation / counter await self.strategy_completed(cryptocurrency, symbol) except errors.UnsetTentacleEvaluation as e: if evaluator_type == evaluators_enums.EvaluatorMatrixTypes.TA.value: self.logger.error(f"Missing technical evaluator data for ({e})") # otherwise it's a social or real-time evaluator, it will shortly be taken into account by TA update cycle except Exception as e: self.logger.exception(e, True, f"Error when computing strategy evaluation: {e}") class TechnicalAnalysisStrategyEvaluator(evaluators.StrategyEvaluator): TIME_FRAMES_TO_WEIGHT = "time_frames_to_weight" TIME_FRAME = "time_frame" WEIGHT = "weight" DEFAULT_WEIGHT = 50 def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.allowed_evaluator_types = [evaluators_enums.EvaluatorMatrixTypes.TA.value, evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value] config = tentacles_manager_api.get_tentacle_config(self.tentacles_setup_config, self.__class__) if config: self.weight_by_time_frames = TechnicalAnalysisStrategyEvaluator._get_weight_by_time_frames( config[TechnicalAnalysisStrategyEvaluator.TIME_FRAMES_TO_WEIGHT] ) def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ super().init_user_inputs(inputs) time_frames_and_weight = [] config_time_frames_and_weight = self.UI.user_input( self.TIME_FRAMES_TO_WEIGHT, commons_enums.UserInputTypes.OBJECT_ARRAY, time_frames_and_weight, inputs, other_schema_values={"minItems": 1, "uniqueItems": True}, item_title="Time frame", title="Analysed time frames and their associated weight." ) # init one user input to generate user input schema and default values time_frames_and_weight.append(self._init_tf_and_weight(inputs, commons_enums.TimeFrames.THIRTY_MINUTES, 30)) self.weight_by_time_frames = TechnicalAnalysisStrategyEvaluator._get_weight_by_time_frames( config_time_frames_and_weight ) def _init_tf_and_weight(self, inputs, timeframe, weight): return { self.TIME_FRAME: self.UI.user_input(self.TIME_FRAME, commons_enums.UserInputTypes.OPTIONS, timeframe.value, inputs, options=[tf.value for tf in commons_enums.TimeFrames], parent_input_name=self.TIME_FRAMES_TO_WEIGHT, array_indexes=[0], title="Time frame"), self.WEIGHT: self.UI.user_input(self.WEIGHT, commons_enums.UserInputTypes.FLOAT, weight, inputs, min_val=0, max_val=100, parent_input_name=self.TIME_FRAMES_TO_WEIGHT, array_indexes=[0], title="Weight of this time frame. This is a multiplier: 0 means this time " "frame is ignored, 100 means it's 100 times more impactful than another " "time frame with a weight of 1."), } async def matrix_callback(self, matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol, time_frame): if evaluator_type not in self.allowed_evaluator_types: # only wake up on relevant callbacks return try: TA_by_timeframe = { available_time_frame: matrix.get_evaluations_by_evaluator( matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.TA.value, cryptocurrency, symbol, available_time_frame.value, allow_missing=False, allowed_values=[commons_constants.START_PENDING_EVAL_NOTE]) for available_time_frame in self.strategy_time_frames } if evaluator_type == evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value: # trigger re-evaluation exchange_id = trading_api.get_exchange_id_from_matrix_id(exchange_name, matrix_id) await evaluators_channel.trigger_technical_evaluators_re_evaluation_with_updated_data(matrix_id, evaluator_name, evaluator_type, exchange_name, cryptocurrency, symbol, exchange_id, self.strategy_time_frames) # do not continue this evaluation return total_evaluation = 0 total_weights = 0 for time_frame, eval_by_ta in TA_by_timeframe.items(): for evaluation in eval_by_ta.values(): eval_value = evaluators_api.get_value(evaluation) if evaluators_util.check_valid_eval_note(eval_value, eval_type=evaluators_api.get_type(evaluation), expected_eval_type=evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE): weight = self.weight_by_time_frames.get(time_frame.value, self.DEFAULT_WEIGHT) total_evaluation += eval_value * weight total_weights += weight if total_weights > 0: self.eval_note = total_evaluation / total_weights await self.strategy_completed(cryptocurrency, symbol) except errors.UnsetTentacleEvaluation as e: self.logger.error(f"Missing technical evaluator data for ({e})") @staticmethod def _get_weight_by_time_frames(tf_to_weight): return { tf_and_weight[TechnicalAnalysisStrategyEvaluator.TIME_FRAME]: tf_and_weight[TechnicalAnalysisStrategyEvaluator.WEIGHT] for tf_and_weight in tf_to_weight } ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/resources/SimpleStrategyEvaluator.md ================================================ SimpleStrategyEvaluator is the most flexible strategy. Meant to be customized, it is using every activated technical, social and real time evaluator, and averages the evaluation value of each to compute its final evaluation. This strategy can be used to make trading signals using as many evaluators as required. Used time frames are 1h, 4h and 1d by default. Warning: this strategy only considers evaluators with evaluations values between -1 and 1. ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/resources/TechnicalAnalysisStrategyEvaluator.md ================================================ TechnicalAnalysisStrategyEvaluator a flexible technical analysis strategy. Meant to be customized, it is using every activated technical evaluator and averages the evaluation value of each to compute its final evaluation. This strategy makes it possible to assign a weight to any time frame in order to make the related technical evaluations more or less impactful for the final strategy evaluation. If not specified for a time frame, default weight is 50. This strategy can be used to create custom trading signals using as many technical evaluators as desired. TechnicalAnalysisStrategyEvaluator can also use real time evaluators to trigger an instant re-evaluation of its technical evaluators and react quickly. The evaluation value of these real time evaluators will not be considered in the final strategy evaluation as they are only meant to trigger an emergency re-evaluation. Used time frames are 30m, 1h, 2h, 4h and 1d by default. Warning: this strategy only considers evaluators with evaluations values between -1 and 1. ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/tests/test_simple_strategy_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import pytest import tests.functional_tests.strategy_evaluators_tests.abstract_strategy_test as abstract_strategy_test import tentacles.Evaluator.Strategies as Strategies import tentacles.Trading.Mode as Mode # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest.fixture def strategy_tester(): strategy_tester_instance = SimpleStrategyEvaluatorTest() strategy_tester_instance.initialize(Strategies.SimpleStrategyEvaluator, Mode.DailyTradingMode) return strategy_tester_instance class SimpleStrategyEvaluatorTest(abstract_strategy_test.AbstractStrategyTest): """ About using this test framework: To be called by pytest, tests have to be called manually since the cythonized version of AbstractStrategyTest creates an __init__() which prevents the default pytest tests collect process """ async def test_default_run(self): # market: -13.599062133645944 await self.run_test_default_run(decimal.Decimal(str(-1.090))) async def test_slow_downtrend(self): # market: -13.599062133645944 # market: -44.248234106962656 # market: -34.87003936300901 # market: -45.18518518518518 await self.run_test_slow_downtrend(decimal.Decimal(str(-1.090)), decimal.Decimal(str(-36.523)), decimal.Decimal(str(-27.337)), decimal.Decimal(str(-31.155))) async def test_sharp_downtrend(self): # market: -30.271723049610415 # market: -32.091097308488614 await self.run_test_sharp_downtrend(decimal.Decimal(str(-24.356)), decimal.Decimal(str(-32.781))) async def test_flat_markets(self): # market: 5.052093571849795 # market: 3.4840425531915002 # market: -12.732688011913623 # market: -34.64150943396227 await self.run_test_flat_markets(decimal.Decimal(str(0.027)), decimal.Decimal(str(11.215)), decimal.Decimal(str(-13.888)), decimal.Decimal(str(-4.472))) async def test_slow_uptrend(self): # market: 32.524679029957184 # market: 6.25 await self.run_test_slow_uptrend(decimal.Decimal(str(15.031)), decimal.Decimal(str(0.831))) async def test_sharp_uptrend(self): # market: 24.56254050550875 # market: 8.665472458575891 await self.run_test_sharp_uptrend(decimal.Decimal(str(14.212)), decimal.Decimal(str(13.007))) async def test_up_then_down(self): # market: 1.1543668450702853 await self.run_test_up_then_down(decimal.Decimal(str(2.674))) async def test_default_run(strategy_tester): await strategy_tester.test_default_run() async def test_slow_downtrend(strategy_tester): await strategy_tester.test_slow_downtrend() async def test_sharp_downtrend(strategy_tester): await strategy_tester.test_sharp_downtrend() async def test_flat_markets(strategy_tester): await strategy_tester.test_flat_markets() async def test_slow_uptrend(strategy_tester): await strategy_tester.test_slow_uptrend() async def test_sharp_uptrend(strategy_tester): await strategy_tester.test_sharp_uptrend() async def test_up_then_down(strategy_tester): await strategy_tester.test_up_then_down() ================================================ FILE: Evaluator/Strategies/mixed_strategies_evaluator/tests/test_technical_analysis_strategy_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import pytest import tests.functional_tests.strategy_evaluators_tests.abstract_strategy_test as abstract_strategy_test import tentacles.Evaluator.Strategies as Strategies import tentacles.Trading.Mode as Mode # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest.fixture def strategy_tester(): strategy_tester_instance = TechnicalAnalysisStrategyEvaluatorTest() strategy_tester_instance.initialize(Strategies.TechnicalAnalysisStrategyEvaluator, Mode.DailyTradingMode) return strategy_tester_instance class TechnicalAnalysisStrategyEvaluatorTest(abstract_strategy_test.AbstractStrategyTest): """ About using this test framework: To be called by pytest, tests have to be called manually since the cythonized version of AbstractStrategyTest creates an __init__() which prevents the default pytest tests collect process """ async def test_default_run(self): # market: -12.052505966587105 await self.run_test_default_run(decimal.Decimal(str(-8.699))) async def test_slow_downtrend(self): # market: -12.052505966587105 # market: -15.195702225633141 # market: -29.12366137549725 # market: -32.110091743119256 await self.run_test_slow_downtrend(decimal.Decimal(str(-8.699)), decimal.Decimal(str(-9.671)), decimal.Decimal(str(-16.968)), decimal.Decimal(str(-7.236))) async def test_sharp_downtrend(self): # market: -26.07183938094741 # market: -32.1654501216545 await self.run_test_sharp_downtrend(decimal.Decimal(str(-19.903)), decimal.Decimal(str(-23.076))) async def test_flat_markets(self): # market: -10.560669456066947 # market: -3.401191658391241 # market: -5.7854560064282765 # market: -8.067940552016978 await self.run_test_flat_markets(decimal.Decimal(str(0.289)), decimal.Decimal(str(1.813)), decimal.Decimal(str(-4.596)), decimal.Decimal(str(3.884))) async def test_slow_uptrend(self): # market: 17.203948364436457 # market: 16.19613670133728 await self.run_test_slow_uptrend(decimal.Decimal(str(8.245)), decimal.Decimal(str(2.882))) async def test_sharp_uptrend(self): # market: 30.881852230166828 # market: 12.28597871355852 await self.run_test_sharp_uptrend(decimal.Decimal(str(1.418)), decimal.Decimal(str(4.362))) async def test_up_then_down(self): # market: -6.040105108015155 await self.run_test_up_then_down(decimal.Decimal(str(-0.964))) async def test_default_run(strategy_tester): await strategy_tester.test_default_run() async def test_slow_downtrend(strategy_tester): await strategy_tester.test_slow_downtrend() async def test_sharp_downtrend(strategy_tester): await strategy_tester.test_sharp_downtrend() async def test_flat_markets(strategy_tester): await strategy_tester.test_flat_markets() async def test_slow_uptrend(strategy_tester): await strategy_tester.test_slow_uptrend() async def test_sharp_uptrend(strategy_tester): await strategy_tester.test_sharp_uptrend() async def test_up_then_down(strategy_tester): await strategy_tester.test_up_then_down() ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/__init__.py ================================================ from .move_signals_strategy import MoveSignalsStrategyEvaluator ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/config/MoveSignalsStrategyEvaluator.json ================================================ { "required_time_frames" : ["30m", "1h", "4h"], "required_evaluators" : ["InstantFluctuationsEvaluator", "KlingerOscillatorMomentumEvaluator", "BBMomentumEvaluator"], "default_config" : ["KlingerOscillatorMomentumEvaluator", "BBMomentumEvaluator"] } ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["MoveSignalsStrategyEvaluator"], "tentacles-requirements": ["momentum_evaluator.py"] } ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/move_signals_strategy.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enum import octobot_evaluators.api.matrix as evaluators_api import octobot_evaluators.evaluators.channel as evaluators_channel import octobot_evaluators.matrix as matrix import octobot_evaluators.enums as evaluators_enums import octobot_evaluators.errors as errors import octobot_evaluators.evaluators as evaluators import octobot_trading.api as trading_api import tentacles.Evaluator.TA as TA class MoveSignalsStrategyEvaluator(evaluators.StrategyEvaluator): SIGNAL_CLASS_NAME = TA.KlingerOscillatorMomentumEvaluator.get_name() WEIGHT_CLASS_NAME = TA.BBMomentumEvaluator.get_name() SHORT_PERIOD_WEIGHT = 4 MEDIUM_PERIOD_WEIGHT = 3 LONG_PERIOD_WEIGHT = 3 SIGNAL_MINIMUM_THRESHOLD = 0.15 def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.evaluation_time_frames = [commons_enum.TimeFrames.THIRTY_MINUTES.value, commons_enum.TimeFrames.ONE_HOUR.value, commons_enum.TimeFrames.FOUR_HOURS.value] self.weights_and_period_evals = [] self.short_period_eval = None self.medium_period_eval = None self.long_period_eval = None def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ pass async def matrix_callback(self, matrix_id, evaluator_name, evaluator_type, eval_note, eval_note_type, exchange_name, cryptocurrency, symbol, time_frame): if evaluator_type == evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value: # trigger re-evaluation exchange_id = trading_api.get_exchange_id_from_matrix_id(exchange_name, matrix_id) await evaluators_channel.trigger_technical_evaluators_re_evaluation_with_updated_data(matrix_id, evaluator_name, evaluator_type, exchange_name, cryptocurrency, symbol, exchange_id, self.strategy_time_frames) # do not continue this evaluation return elif evaluator_type == evaluators_enums.EvaluatorMatrixTypes.TA.value: try: TA_by_timeframe = { available_time_frame: matrix.get_evaluations_by_evaluator( matrix_id, exchange_name, evaluators_enums.EvaluatorMatrixTypes.TA.value, cryptocurrency, symbol, available_time_frame.value, allow_missing=False, allowed_values=[commons_constants.START_PENDING_EVAL_NOTE]) for available_time_frame in self.strategy_time_frames } self._refresh_evaluations(TA_by_timeframe) self._compute_final_evaluation() await self.strategy_completed(cryptocurrency, symbol) except errors.UnsetTentacleEvaluation as e: self.logger.debug(f"Tentacles evaluation initialization: not ready yet for a strategy update ({e})") except KeyError as e: self.logger.exception(e, True, f"Missing {e} evaluation in matrix for {symbol} on {time_frame}, " f"did you activate the required evaluator ?") def _compute_final_evaluation(self): weights = 0 composite_evaluation = 0 for weight, evaluation in self.weights_and_period_evals: composite_evaluation += self._compute_fractal_evaluation(evaluation, weight) weights += weight self.eval_note = composite_evaluation / weights @staticmethod def _compute_fractal_evaluation(signal_with_weight, multiplier): if signal_with_weight.signal != commons_constants.START_PENDING_EVAL_NOTE \ and signal_with_weight.weight != commons_constants.START_PENDING_EVAL_NOTE: evaluation_sign = signal_with_weight.signal * signal_with_weight.weight if abs(signal_with_weight.signal) >= MoveSignalsStrategyEvaluator.SIGNAL_MINIMUM_THRESHOLD \ and evaluation_sign > 0: eval_side = 1 if signal_with_weight.signal > 0 else -1 signal_strength = 2 * signal_with_weight.signal * signal_with_weight.weight weighted_eval = min(signal_strength, 1) return weighted_eval * multiplier * eval_side return 0 def _refresh_evaluations(self, TA_by_timeframe): for _, evaluation in self.weights_and_period_evals: evaluation.refresh_evaluation(TA_by_timeframe) def _get_tentacle_registration_topic(self, all_symbols_by_crypto_currencies, time_frames, real_time_time_frames): currencies, symbols, time_frames = super()._get_tentacle_registration_topic(all_symbols_by_crypto_currencies, time_frames, real_time_time_frames) # register evaluation fractals based on available time frames self._register_time_frame(commons_enum.TimeFrames.THIRTY_MINUTES, self.SHORT_PERIOD_WEIGHT) self._register_time_frame(commons_enum.TimeFrames.ONE_HOUR, self.MEDIUM_PERIOD_WEIGHT) self._register_time_frame(commons_enum.TimeFrames.FOUR_HOURS, self.LONG_PERIOD_WEIGHT) return currencies, symbols, time_frames def _register_time_frame(self, time_frame, weight): if time_frame in self.strategy_time_frames: self.weights_and_period_evals.append((weight, SignalWithWeight(time_frame))) else: self.logger.warning(f"Missing {time_frame.value} time frame on {self.exchange_name}, " f"this strategy will not work at its optimal potential.") class SignalWithWeight: def __init__(self, time_frame): self.time_frame = time_frame self.signal = commons_constants.START_PENDING_EVAL_NOTE self.weight = commons_constants.START_PENDING_EVAL_NOTE def reset_evaluation(self): self.signal = commons_constants.START_PENDING_EVAL_NOTE self.weight = commons_constants.START_PENDING_EVAL_NOTE def refresh_evaluation(self, TA_by_timeframe): self.reset_evaluation() self.signal = evaluators_api.get_value( TA_by_timeframe[self.time_frame][MoveSignalsStrategyEvaluator.SIGNAL_CLASS_NAME]) self.weight = evaluators_api.get_value( TA_by_timeframe[self.time_frame][MoveSignalsStrategyEvaluator.WEIGHT_CLASS_NAME]) ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/resources/MoveSignalsStrategyEvaluator.md ================================================ MoveSignalsStrategyEvaluator is a fractal strategy: it is using different time frames to balance decisions. This strategy is using the KlingerOscillatorMomentumEvaluator based on the [Klinger Oscillator](https://www.investopedia.com/terms/k/klingeroscillator.asp) to know when to start a trade and BBMomentumEvaluator based on [Bollinger Bands](https://www.investopedia.com/terms/b/bollingerbands.asp) to know how much weight to give to this trade. This strategy is updated at the end of each candle on the watched time frame which is each 30 minutes. It is also possible to make it trigger automatically using a real-time evaluator. Using a real time evaluator that signals sudden market changes like the InstantFluctuationsEvaluator will make MoveSignalsStrategyEvaluator also wake up on such events. Used time frames are 30m, 1h and 4h. Warning: MoveSignalsStrategyEvaluator only works on liquid markets because the Klinger Oscillator requires enough volume and candles continuity to be accurate. ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Evaluator/Strategies/move_signals_strategy_evaluator/tests/test_move_signals_strategy_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import pytest import tests.functional_tests.strategy_evaluators_tests.abstract_strategy_test as abstract_strategy_test import tentacles.Evaluator.Strategies as Strategies import tentacles.Trading.Mode as Mode # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest.fixture def strategy_tester(): strategy_tester_instance = MoveSignalsStrategyEvaluatorTest() strategy_tester_instance.initialize(Strategies.MoveSignalsStrategyEvaluator, Mode.SignalTradingMode) return strategy_tester_instance class MoveSignalsStrategyEvaluatorTest(abstract_strategy_test.AbstractStrategyTest): """ About using this test framework: To be called by pytest, tests have to be called manually since the cythonized version of AbstractStrategyTest creates an __init__() which prevents the default pytest tests collect process """ async def test_default_run(self): # market: -12.052505966587105 await self.run_test_default_run(decimal.Decimal(str(-2.549))) async def test_slow_downtrend(self): # market: -12.052505966587105 # market: -15.195702225633141 # market: -29.12366137549725 # market: -32.110091743119256 await self.run_test_slow_downtrend(decimal.Decimal(str(-2.549)), decimal.Decimal(str(-3.452)), decimal.Decimal(str(-17.393)), decimal.Decimal(str(-15.761))) async def test_sharp_downtrend(self): # market: -26.07183938094741 # market: -32.1654501216545 await self.run_test_sharp_downtrend(decimal.Decimal(str(-12.078)), decimal.Decimal(str(-10.3))) async def test_flat_markets(self): # market: -10.560669456066947 # market: -3.401191658391241 # market: -5.7854560064282765 # market: -8.067940552016978 await self.run_test_flat_markets(decimal.Decimal(str(-0.200)), decimal.Decimal(str(0.353)), decimal.Decimal(str(-8.126)), decimal.Decimal(str(-7.038))) async def test_slow_uptrend(self): # market: 17.203948364436457 # market: 16.19613670133728 await self.run_test_slow_uptrend(decimal.Decimal(str(10.278)), decimal.Decimal(str(4.299))) async def test_sharp_uptrend(self): # market: 30.881852230166828 # market: 12.28597871355852 await self.run_test_sharp_uptrend(decimal.Decimal(str(6.504)), decimal.Decimal(str(5.411))) async def test_up_then_down(self): # market: -6.040105108015155 await self.run_test_up_then_down(decimal.Decimal(str(-6.691))) async def test_default_run(strategy_tester): await strategy_tester.test_default_run() async def test_slow_downtrend(strategy_tester): await strategy_tester.test_slow_downtrend() async def test_sharp_downtrend(strategy_tester): await strategy_tester.test_sharp_downtrend() async def test_flat_markets(strategy_tester): await strategy_tester.test_flat_markets() async def test_slow_uptrend(strategy_tester): await strategy_tester.test_slow_uptrend() async def test_sharp_uptrend(strategy_tester): await strategy_tester.test_sharp_uptrend() async def test_up_then_down(strategy_tester): await strategy_tester.test_up_then_down() ================================================ FILE: Evaluator/TA/ai_evaluator/__init__.py ================================================ from .ai import GPTEvaluator ================================================ FILE: Evaluator/TA/ai_evaluator/ai.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tulipy import os import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.enums as enums import octobot_commons.os_util as os_util import octobot_commons.data_util as data_util import octobot_evaluators.evaluators as evaluators import octobot_evaluators.util as evaluators_util import octobot_evaluators.errors as evaluators_errors import octobot_trading.api as trading_api import octobot_services.api as services_api import octobot_services.errors as services_errors import tentacles.Services.Services_bases def _get_gpt_service(): try: return tentacles.Services.Services_bases.GPTService except (AttributeError, ImportError): raise ImportError("the gpt_service tentacle is not installed") class GPTEvaluator(evaluators.TAEvaluator): GLOBAL_VERSION = 1 PREPROMPT = "Predict: {up or down} {confidence%} (no other information)" PASSED_DATA_LEN = 10 MAX_CONFIDENCE_PERCENT = 100 HIGH_CONFIDENCE_PERCENT = 80 MEDIUM_CONFIDENCE_PERCENT = 50 LOW_CONFIDENCE_PERCENT = 30 INDICATORS = { "No indicator: raw candles price data": lambda data, period: data, "EMA: Exponential Moving Average": tulipy.ema, "SMA: Simple Moving Average": tulipy.sma, "Kaufman Adaptive Moving Average": tulipy.kama, "Hull Moving Average": tulipy.kama, "RSI: Relative Strength Index": tulipy.rsi, "Detrended Price Oscillator": tulipy.dpo, } SOURCES = ["Open", "High", "Low", "Close", "Volume", "Full candle (For no indicator only)"] ALLOW_GPT_REEVALUATION_ENV = "ALLOW_GPT_REEVALUATIONS" GPT_MODELS = [] ALLOW_TOKEN_LIMIT_UPDATE = False def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.indicator = None self.source = None self.period = None self.min_confidence_threshold = 100 self.gpt_model = _get_gpt_service().DEFAULT_MODEL self.is_backtesting = False self.min_allowed_timeframe = os.getenv("MIN_GPT_TIMEFRAME", None) self.enable_model_selector = os_util.parse_boolean_environment_var("ENABLE_GPT_MODELS_SELECTOR", "True") self._min_allowed_timeframe_minutes = 0 try: if self.min_allowed_timeframe: self._min_allowed_timeframe_minutes = \ commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(self.min_allowed_timeframe)] except ValueError: self.logger.error(f"Invalid timeframe configuration: unknown timeframe: '{self.min_allowed_timeframe}'") self.allow_reevaluations = os_util.parse_boolean_environment_var(self.ALLOW_GPT_REEVALUATION_ENV, "True") self.gpt_tokens_limit = _get_gpt_service().NO_TOKEN_LIMIT_VALUE self.services_config = None def enable_reevaluation(self) -> bool: """ Override when artificial re-evaluations from the evaluator channel can be disabled """ return self.allow_reevaluations @classmethod def get_signals_history_type(cls): """ Override when this evaluator uses a specific type of signal history """ return commons_enums.SignalHistoryTypes.GPT async def load_and_save_user_inputs(self, bot_id: str) -> dict: """ instance method API for user inputs Initialize and save the tentacle user inputs in run data :return: the filled user input configuration """ self.is_backtesting = self._is_in_backtesting() if self.is_backtesting and not _get_gpt_service().BACKTESTING_ENABLED: self.logger.error(f"{self.get_name()} is disabled in backtesting. It will only emit neutral evaluations") await self._init_GPT_models() return await super().load_and_save_user_inputs(bot_id) def init_user_inputs(self, inputs: dict) -> None: self.indicator = self.UI.user_input( "indicator", enums.UserInputTypes.OPTIONS, next(iter(self.INDICATORS)), inputs, options=list(self.INDICATORS), title="Indicator: the technical indicator to apply and give the result of to chat GPT." ) self.source = self.UI.user_input( "source", enums.UserInputTypes.OPTIONS, self.SOURCES[3], inputs, options=self.SOURCES, title="Source: values of candles data to pass to the indicator." ) self.period = self.UI.user_input( "period", enums.UserInputTypes.INT, self.period, inputs, min_val=1, title="Period: length of the indicator period or the number of candles to give to ChatGPT." ) self.min_confidence_threshold = self.UI.user_input( "min_confidence_threshold", enums.UserInputTypes.INT, self.min_confidence_threshold, inputs, min_val=0, max_val=100, title="Minimum confidence threshold: % confidence value starting from which to return 1 or -1." ) if self.enable_model_selector: current_value = self.specific_config.get("GPT_model") models = list(self.GPT_MODELS) or ( [current_value] if current_value else [_get_gpt_service().DEFAULT_MODEL] ) self.gpt_model = self.UI.user_input( "GPT model", enums.UserInputTypes.OPTIONS, _get_gpt_service().DEFAULT_MODEL, inputs, options=sorted(models), title="GPT Model: the GPT model to use. Enable the evaluator to load other models." ) if os_util.parse_boolean_environment_var(self.ALLOW_GPT_REEVALUATION_ENV, "True"): self.allow_reevaluations = self.UI.user_input( "allow_reevaluation", enums.UserInputTypes.BOOLEAN, self.allow_reevaluations, inputs, title="Allow Reevaluation: send a ChatGPT request when realtime evaluators trigger a " "global reevaluation Use latest available value otherwise. " "Warning: enabling this can lead to a large amount of GPT requests and consumed tokens." ) if self.ALLOW_TOKEN_LIMIT_UPDATE: self.gpt_tokens_limit = self.UI.user_input( "max_gpt_tokens", enums.UserInputTypes.INT, self.gpt_tokens_limit, inputs, min_val=_get_gpt_service().NO_TOKEN_LIMIT_VALUE, title=f"OpenAI token limit: maximum daily number of tokens to consume with a given OctoBot instance. " f"Use {_get_gpt_service().NO_TOKEN_LIMIT_VALUE} to remove the limit." ) async def _init_GPT_models(self): if not self.GPT_MODELS: self.GPT_MODELS = [_get_gpt_service().DEFAULT_MODEL] if self.enable_model_selector and not self.is_backtesting: try: service = await services_api.get_service( _get_gpt_service(), self.is_backtesting, self.services_config ) self.GPT_MODELS = service.models self.ALLOW_TOKEN_LIMIT_UPDATE = service.allow_token_limit_update() except Exception as err: self.logger.exception(err, True, f"Impossible to fetch GPT models: {err}") async def _init_registered_topics(self, all_symbols_by_crypto_currencies, currencies, symbols, time_frames): await super()._init_registered_topics(all_symbols_by_crypto_currencies, currencies, symbols, time_frames) for time_frame in time_frames: if not self._check_timeframe(time_frame.value): self.logger.error(f"{time_frame.value} time frame will be ignored for {self.get_name()} " f"as {time_frame.value} is not allowed in this configuration. " f"The shortest allowed time frame is {self.min_allowed_timeframe}. {self.get_name()} " f"will emit neutral evaluations on this time frame.") async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = self.get_candles_data(exchange, exchange_id, symbol, time_frame, inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): async with self.async_evaluation(): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if self._check_timeframe(time_frame): try: candle_time = candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] computed_data = self.call_indicator(candle_data) formatted_data = self.get_formatted_data(computed_data) prediction = await self.ask_gpt(self.PREPROMPT, formatted_data, symbol, time_frame, candle_time) \ or "" cleaned_prediction = prediction.strip().replace("\n", "").replace(".", "").lower() prediction_side = self._parse_prediction_side(cleaned_prediction) if prediction_side == 0 and not self.is_backtesting: self.logger.warning( f"Ignored ChatGPT answer for {symbol} {time_frame}, answer: '{cleaned_prediction}': " f"missing prediction or % accuracy." ) return confidence = self._parse_confidence(cleaned_prediction) / 100 self.eval_note = prediction_side * confidence except services_errors.InvalidRequestError as e: self.logger.error(f"Invalid GPT request: {e}") except services_errors.RateLimitError as e: self.logger.error(f"Impossible to get ChatGPT evaluation for {symbol} on {time_frame}: " f"No remaining free tokens for today : {e}. To prevent this, you can reduce the " f"amount of traded pairs, use larger time frames or increase the maximum " f"allowed tokens.") except services_errors.UnavailableInBacktestingError: # error already logged error for backtesting in use_backtesting_init_timeout pass except evaluators_errors.UnavailableEvaluatorError as e: self.logger.exception(e, True, f"Evaluation error: {e}") except tulipy.lib.InvalidOptionError as e: self.logger.warning( f"Error when computing {self.indicator} on {self.period} period with {len(candle_data)} " f"candles: {e}" ) self.logger.exception(e, False) else: self.logger.debug(f"Ignored {time_frame} time frame as the shorted allowed time frame is " f"{self.min_allowed_timeframe}") await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) def get_formatted_data(self, computed_data) -> str: if self.source in self.get_unformated_sources(): return str(computed_data) reduced_data = computed_data[-self.PASSED_DATA_LEN:] return ", ".join(str(datum).replace('[', '').replace(']', '') for datum in reduced_data) async def ask_gpt(self, preprompt, inputs, symbol, time_frame, candle_time) -> str: try: service = await services_api.get_service( _get_gpt_service(), self.is_backtesting, {} if self.is_backtesting else self.services_config ) service.apply_daily_token_limit_if_possible(self.gpt_tokens_limit) model = self.gpt_model if self.enable_model_selector else None resp = await service.get_chat_completion( [ service.create_message("system", preprompt, model=model), service.create_message("user", inputs, model=model), ], model=model, exchange=self.exchange_name, symbol=symbol, time_frame=time_frame, version=self.get_version(), candle_open_time=candle_time, use_stored_signals=self.is_backtesting ) self.logger.info( f"GPT's answer is '{resp}' for {symbol} on {time_frame} with input: {inputs} " f"and candle_time: {candle_time}" ) return resp except services_errors.CreationError as err: raise evaluators_errors.UnavailableEvaluatorError(f"Impossible to get ChatGPT prediction: {err}") from err def get_version(self): # later on, identify by its specs # return f"{self.gpt_model}-{self.source}-{self.indicator}-{self.period}-{self.GLOBAL_VERSION}" return "0.0.0" def call_indicator(self, candle_data): if self.source in self.get_unformated_sources(): return candle_data return data_util.drop_nan(self.INDICATORS[self.indicator](candle_data, self.period)) def get_candles_data(self, exchange, exchange_id, symbol, time_frame, inc_in_construction_data): if self.source in self.get_unformated_sources(): limit = self.period if inc_in_construction_data else self.period + 1 full_candles = trading_api.get_candles_as_list( trading_api.get_symbol_historical_candles( self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, limit=limit ) ) # remove time value for candle in full_candles: candle.pop(commons_enums.PriceIndexes.IND_PRICE_TIME.value) if inc_in_construction_data: return full_candles return full_candles[:-1] return self.get_candles_data_api()( self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data ) def get_unformated_sources(self): return (self.SOURCES[5], ) def get_candles_data_api(self): return { self.SOURCES[0]: trading_api.get_symbol_open_candles, self.SOURCES[1]: trading_api.get_symbol_high_candles, self.SOURCES[2]: trading_api.get_symbol_low_candles, self.SOURCES[3]: trading_api.get_symbol_close_candles, self.SOURCES[4]: trading_api.get_symbol_volume_candles, }[self.source] def _check_timeframe(self, time_frame): return commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(time_frame)] >= \ self._min_allowed_timeframe_minutes def _parse_prediction_side(self, cleaned_prediction): if "down " in cleaned_prediction: return 1 elif "up " in cleaned_prediction: return -1 return 0 def _parse_confidence(self, cleaned_prediction): """ possible formats: up 70% (most common case) up with 70% confidence up with high confidence """ value = self.LOW_CONFIDENCE_PERCENT if "%" in cleaned_prediction: percent_index = cleaned_prediction.index("%") bracket_index = (cleaned_prediction[:percent_index].rindex("{") + 1) \ if "{" in cleaned_prediction[:percent_index] else 0 value = float(cleaned_prediction[bracket_index:percent_index].split(" ")[-1]) elif "high" in cleaned_prediction: value = self.HIGH_CONFIDENCE_PERCENT elif "medium" in cleaned_prediction or "intermediate" in cleaned_prediction: value = self.MEDIUM_CONFIDENCE_PERCENT elif "low" in cleaned_prediction: value = self.LOW_CONFIDENCE_PERCENT elif not cleaned_prediction: value = 0 else: self.logger.warning(f"Impossible to parse confidence in {cleaned_prediction}. Using low confidence") if value >= self.min_confidence_threshold: return self.MAX_CONFIDENCE_PERCENT return value ================================================ FILE: Evaluator/TA/ai_evaluator/config/GPTEvaluator.json ================================================ { "indicator": "No indicator: raw candles price data", "period": 2, "source": "Close", "min_confidence_threshold": 100, "allow_reevaluation": false, "max_gpt_tokens": -1 } ================================================ FILE: Evaluator/TA/ai_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GPTEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/TA/ai_evaluator/resources/GPTEvaluator.md ================================================ Uses [Chat GPT](https://chat.openai.com/) to predict the next moves of the market. Evaluates between -1 to 1 according to ChatGPT's prediction of the selected data and its confidence. Learn more about ChatGPT trading strategies from our ChatGPT Trading guide.
Example of a trading strategy using ChatGPT and the ChatGPTEvaluator
Any question ? Checkout our [ChatGPT setup guide](https://www.octobot.cloud/en/guides/octobot-interfaces/chatgpt?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=GPTEvaluator) to configure your OctoBot to use ChatGPT. Note: this evaluator can only be used in backtesting for markets where historical ChatGPT data are available. Find the full list of supported historical markets on our [ChatGPT page](https://www.octobot.cloud/features/chatgpt-trading?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=GPTEvaluator). ================================================ FILE: Evaluator/TA/ai_evaluator/tests/test_ai.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import types import mock import pytest import numpy import tentacles.Evaluator.TA.ai_evaluator as ai_evaluator @pytest.fixture def GPT_evaluator(): return ai_evaluator.GPTEvaluator(mock.Mock(is_tentacle_activated=mock.Mock(return_value=True))) def test_indicators(GPT_evaluator): data = numpy.array([100, 223, 123, 23, 134, 124, 434, 3243, 121, 3242.34, 1212, 87, 232.32]) for indicator in GPT_evaluator.INDICATORS: GPT_evaluator.indicator = indicator GPT_evaluator.period = 2 assert len(data) - (GPT_evaluator.period + 1) <= len(GPT_evaluator.call_indicator(data)) <= len(data) def test_get_candles_data_api(GPT_evaluator): for source in GPT_evaluator.SOURCES: GPT_evaluator.source = source if GPT_evaluator.source not in GPT_evaluator.get_unformated_sources(): assert isinstance(GPT_evaluator.get_candles_data_api(), types.FunctionType) def test_parse_prediction_side(GPT_evaluator): assert GPT_evaluator._parse_prediction_side("up 70%") == -1 assert GPT_evaluator._parse_prediction_side("plop up 70%") == -1 assert GPT_evaluator._parse_prediction_side(" up with 70%") == -1 assert GPT_evaluator._parse_prediction_side("Prediction: up with 70% confidence") == -1 assert GPT_evaluator._parse_prediction_side("down 70%") == 1 assert GPT_evaluator._parse_prediction_side("plop down 70%") == 1 assert GPT_evaluator._parse_prediction_side(" down with 70%") == 1 assert GPT_evaluator._parse_prediction_side("Prediction: down with 70% confidence") == 1 def test_parse_confidence(GPT_evaluator): assert GPT_evaluator._parse_confidence("up 70%") == 70 assert GPT_evaluator._parse_confidence("up 54.33%") == 54.33 assert GPT_evaluator._parse_confidence("down 70% confidence blablabla") == 70 assert GPT_evaluator._parse_confidence("Prediction: down 70%") == 70 GPT_evaluator.min_confidence_threshold = 60 assert GPT_evaluator._parse_confidence("up 70%") == 100 assert GPT_evaluator._parse_confidence("up 60%") == 100 assert GPT_evaluator._parse_confidence("up 59%") == 59 ================================================ FILE: Evaluator/TA/momentum_evaluator/__init__.py ================================================ from .momentum import RSIMomentumEvaluator, ADXMomentumEvaluator, RSIWeightMomentumEvaluator, \ BBMomentumEvaluator, MACDMomentumEvaluator, KlingerOscillatorMomentumEvaluator, \ KlingerOscillatorReversalConfirmationMomentumEvaluator, EMAMomentumEvaluator ================================================ FILE: Evaluator/TA/momentum_evaluator/config/ADXMomentumEvaluator.json ================================================ { "period_length": 14 } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/BBMomentumEvaluator.json ================================================ { "period_length": 20 } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/EMAMomentumEvaluator.json ================================================ { "period_length": 21, "price_threshold_percent": 2 } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/KlingerOscillatorMomentumEvaluator.json ================================================ { "ema_signal_period": 13, "long_period": 55, "short_period": 35 } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/KlingerOscillatorReversalConfirmationMomentumEvaluator.json ================================================ { "ema_signal_period": 13, "long_period": 55, "short_period": 35 } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/MACDMomentumEvaluator.json ================================================ { "long_period_length": 26, "short_period_length": 12, "signal_period_length": 9 } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/RSIMomentumEvaluator.json ================================================ { "long_threshold": 30, "period_length": 14, "short_threshold": 70, "trend_change_identifier": true } ================================================ FILE: Evaluator/TA/momentum_evaluator/config/RSIWeightMomentumEvaluator.json ================================================ { "period": 14, "slow_eval_count": 16, "fast_eval_count": 4, "RSI_to_weight": [ { "slow_threshold": 30, "fast_thresholds": [ { "fast_threshold" : 20, "weights": { "price": 2, "volume": 2 } }, { "fast_threshold" : 30, "weights": { "price": 1, "volume": 1 } } ] }, { "slow_threshold": 35, "fast_thresholds": [ { "fast_threshold" : 20, "weights": { "price": 3, "volume": 3 } }, { "fast_threshold" : 35, "weights": { "price": 1, "volume": 1 } } ] }, { "slow_threshold": 45, "fast_thresholds": [ { "fast_threshold" : 20, "weights": { "price": 3, "volume": 3 } }, { "fast_threshold" : 40, "weights": { "price": 2, "volume": 1 } } ] }, { "slow_threshold": 55, "fast_thresholds": [ { "fast_threshold" : 45, "weights": { "price": 1, "volume": 1 } } ] }, { "slow_threshold": 65, "fast_thresholds": [ { "fast_threshold" : 45, "weights": { "price": 1, "volume": 1 } }, { "fast_threshold" : 55, "weights": { "price": 3, "volume": 2 } }, { "fast_threshold" : 60, "weights": { "price": 2, "volume": 1 } } ] }, { "slow_threshold": 70, "fast_thresholds": [ { "fast_threshold" : 55, "weights": { "price": 3, "volume": 2 } }, { "fast_threshold" : 70, "weights": { "price": 2, "volume": 2 } } ] } ] } ================================================ FILE: Evaluator/TA/momentum_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["RSIMomentumEvaluator", "ADXMomentumEvaluator", "RSIWeightMomentumEvaluator", "BBMomentumEvaluator", "MACDMomentumEvaluator", "KlingerOscillatorMomentumEvaluator", "KlingerOscillatorReversalConfirmationMomentumEvaluator", "EMAMomentumEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/TA/momentum_evaluator/momentum.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import math import numpy import tulipy import typing import octobot_commons.constants as commons_constants import octobot_commons.enums as enums import octobot_commons.data_util as data_util import octobot_evaluators.evaluators as evaluators import octobot_evaluators.util as evaluators_util import octobot_trading.api as trading_api import tentacles.Evaluator.Util as EvaluatorUtil class RSIMomentumEvaluator(evaluators.TAEvaluator): PERIOD_LENGTH = "period_length" TREND_CHANGE_IDENTIFIER = "trend_change_identifier" LONG_THRESHOLD = "long_threshold" SHORT_THRESHOLD = "short_threshold" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.pertinence = 1 self.period_length = 14 self.short_threshold = 70 self.long_threshold = 30 self.is_trend_change_identifier = True self.short_term_averages = [7, 5, 4, 3, 2, 1] self.long_term_averages = [40, 30, 20, 15, 10] def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the evaluator, should define all the evaluator's user inputs """ default_config = self.get_default_config() self.period_length = self.UI.user_input( self.PERIOD_LENGTH, enums.UserInputTypes.INT, default_config["period_length"], inputs, min_val=0, title="RSI period length" ) self.is_trend_change_identifier = self.UI.user_input( self.TREND_CHANGE_IDENTIFIER, enums.UserInputTypes.BOOLEAN, default_config["trend_change_identifier"], inputs, title="Trend identifier: Identify RSI trend changes and evaluate the trend changes strength", ) self.short_threshold = self.UI.user_input( self.SHORT_THRESHOLD, enums.UserInputTypes.FLOAT, default_config["short_threshold"], inputs, min_val=0, title="Short threshold: RSI value from with to send a short (sell) signal. " "Evaluates as 1 when the current RSI value is equal or higher.", editor_options={ enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "trend_change_identifier": False } } ) self.long_threshold = self.UI.user_input( self.LONG_THRESHOLD, enums.UserInputTypes.FLOAT, default_config["long_threshold"], inputs, min_val=0, title="Long threshold: RSI value from with to send a long (buy) signal. " "Evaluates as -1 when the current RSI value is equal or lower.", editor_options={ enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "trend_change_identifier": False } } ) @classmethod def get_default_config( cls, period_length: typing.Optional[float] = None, trend_change_identifier: typing.Optional[bool] = None, short_threshold: typing.Optional[float] = None, long_threshold: typing.Optional[float] = None ): return { cls.PERIOD_LENGTH: period_length or 14, cls.TREND_CHANGE_IDENTIFIER: True if trend_change_identifier is None else trend_change_identifier, cls.SHORT_THRESHOLD: short_threshold or 70, cls.LONG_THRESHOLD: long_threshold or 30, } async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): updated_value = False if candle_data is not None and len(candle_data) > self.period_length: rsi_v = tulipy.rsi(candle_data, period=self.period_length) if len(rsi_v) and not math.isnan(rsi_v[-1]): if self.is_trend_change_identifier: long_trend = EvaluatorUtil.TrendAnalysis.get_trend(rsi_v, self.long_term_averages) short_trend = EvaluatorUtil.TrendAnalysis.get_trend(rsi_v, self.short_term_averages) # check if trend change if short_trend > 0 > long_trend: # trend changed to up self.set_eval_note(-short_trend) elif long_trend > 0 > short_trend: # trend changed to down self.set_eval_note(short_trend) # use RSI current value last_rsi_value = rsi_v[-1] if last_rsi_value > 50: self.set_eval_note(rsi_v[-1] / 200) else: self.set_eval_note((rsi_v[-1] - 100) / 200) else: self.eval_note = 0 if rsi_v[-1] >= self.short_threshold: self.eval_note = 1 elif rsi_v[-1] <= self.long_threshold: self.eval_note = -1 updated_value = True if not self.is_trend_change_identifier and not updated_value: self.eval_note = 0 await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) @classmethod def get_is_symbol_wildcard(cls) -> bool: """ :return: True if the evaluator is not symbol dependant else False """ return False @classmethod def get_is_time_frame_wildcard(cls) -> bool: """ :return: True if the evaluator is not time_frame dependant else False """ return False # double RSI analysis class RSIWeightMomentumEvaluator(evaluators.TAEvaluator): PERIOD = "period" SLOW_EVAL_COUNT = "slow_eval_count" FAST_EVAL_COUNT = "fast_eval_count" RSI_TO_WEIGHTS = "RSI_to_weight" SLOW_THRESHOLD = "slow_threshold" FAST_THRESHOLD = "fast_threshold" FAST_THRESHOLDS = "fast_thresholds" WEIGHTS = "weights" PRICE = "price" VOLUME = "volume" @staticmethod def get_eval_type(): return typing.Dict[str, int] def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.period_length = 14 self.slow_eval_count = 16 self.fast_eval_count = 4 self.weights = [] def _init_fast_threshold(self, inputs, indexes, fast_threshold, price_weight, volume_weight): self.UI.user_input(self.WEIGHTS, enums.UserInputTypes.OBJECT, None, inputs, parent_input_name=self.FAST_THRESHOLDS, title="Price and volume weights of this interpretation.", array_indexes=indexes) return { self.FAST_THRESHOLD: self.UI.user_input(self.FAST_THRESHOLD, enums.UserInputTypes.INT, fast_threshold, inputs, min_val=0, parent_input_name=self.FAST_THRESHOLDS, title="Fast RSI threshold under which this interpretation will " "be triggered.", array_indexes=indexes), self.WEIGHTS: { self.PRICE: self.UI.user_input(self.PRICE, enums.UserInputTypes.OPTIONS, price_weight, inputs, options=[1, 2, 3], parent_input_name=self.WEIGHTS, editor_options={"enum_titles": ["Light", "Average", "Heavy"]}, title="Price weight.", array_indexes=indexes), self.VOLUME: self.UI.user_input(self.VOLUME, enums.UserInputTypes.OPTIONS, volume_weight, inputs, options=[1, 2, 3], parent_input_name=self.WEIGHTS, editor_options={"enum_titles": ["Light", "Average", "Heavy"]}, title="Volume weight.", array_indexes=indexes), } } def _init_RSI_to_weight(self, inputs, slow_threshold, fast_thresholds): self.UI.user_input(self.FAST_THRESHOLDS, enums.UserInputTypes.OBJECT_ARRAY, fast_thresholds, inputs, item_title="Fast RSI interpretation", other_schema_values={"minItems": 1, "uniqueItems": True}, parent_input_name=self.RSI_TO_WEIGHTS, title="Interpretations on this slow threshold trigger case."), return { self.SLOW_THRESHOLD: self.UI.user_input(self.SLOW_THRESHOLD, enums.UserInputTypes.INT, slow_threshold, inputs, min_val=0, parent_input_name=self.RSI_TO_WEIGHTS, title="Slow RSI threshold under which this interpretation will " "be triggered.", array_indexes=[0]), self.FAST_THRESHOLDS: [ self._init_fast_threshold(inputs, [0, index], *fast_threshold) for index, fast_threshold in enumerate(fast_thresholds) ], } def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.period_length = self.UI.user_input("period", enums.UserInputTypes.INT, self.period_length, inputs, min_val=1, title="Period: RSI period length.") self.slow_eval_count = self.UI.user_input("slow_eval_count", enums.UserInputTypes.INT, self.slow_eval_count, inputs, min_val=1, title="Number of recent RSI values to consider to get the current slow " "moving market sentiment.") self.fast_eval_count = self.UI.user_input("fast_eval_count", enums.UserInputTypes.INT, self.fast_eval_count, inputs, min_val=1, title="Number of recent RSI values to consider to get the current fast " "moving market sentiment.") weights = [] self.weights = sorted( self.UI.user_input(self.RSI_TO_WEIGHTS, enums.UserInputTypes.OBJECT_ARRAY, weights, inputs, item_title="Slow RSI interpretation", other_schema_values={"minItems": 1, "uniqueItems": True}, title="RSI values and interpretations."), key=lambda a: a[self.SLOW_THRESHOLD] ) # init one user input to generate user input schema and default values weights.append(self._init_RSI_to_weight(inputs, 30, [[20, 2, 2]])) for i, fast_threshold in enumerate(self.weights): fast_threshold[self.FAST_THRESHOLDS] = sorted(fast_threshold[self.FAST_THRESHOLDS], key=lambda a: a[self.FAST_THRESHOLD]) def _get_rsi_averages(self, symbol_candles, time_frame, include_in_construction): # compute the slow and fast RSI average candle_data = trading_api.get_symbol_close_candles(symbol_candles, time_frame, include_in_construction=include_in_construction) if len(candle_data) > self.period_length: rsi_v = tulipy.rsi(candle_data, period=self.period_length) rsi_v = data_util.drop_nan(rsi_v) if len(rsi_v): slow_average = numpy.mean(rsi_v[-self.slow_eval_count:]) fast_average = numpy.mean(rsi_v[-self.fast_eval_count:]) return slow_average, fast_average, rsi_v return None, None, None @staticmethod def _check_inferior(bound, val1, val2): return val1 < bound and val2 < bound def _analyse_dip_weight(self, slow_rsi, fast_rsi, current_rsi): # returns price weight, volume weight try: for slow_rsi_weight in self.weights: if slow_rsi < slow_rsi_weight[self.SLOW_THRESHOLD]: for fast_rsi_weight in slow_rsi_weight[self.FAST_THRESHOLDS]: if self._check_inferior(fast_rsi_weight[self.FAST_THRESHOLD], fast_rsi, current_rsi): return fast_rsi_weight[self.WEIGHTS][self.PRICE], \ fast_rsi_weight[self.WEIGHTS][self.VOLUME] # exit loop since the target RSI has been found break except KeyError as e: self.logger.error(f"Error when reading from config file: missing {e}") return None, None async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): try: symbol_candles = self.get_exchange_symbol_data(exchange, exchange_id, symbol) # compute the slow and fast RSI average slow_rsi, fast_rsi, rsi_v = self._get_rsi_averages(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) current_candle_time = trading_api.get_symbol_time_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data)[ -1] await self.evaluate(cryptocurrency, symbol, time_frame, slow_rsi, fast_rsi, rsi_v, current_candle_time, candle) except IndexError: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE async def evaluate(self, cryptocurrency, symbol, time_frame, slow_rsi, fast_rsi, rsi_v, current_candle_time, candle): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if slow_rsi is not None and fast_rsi is not None and rsi_v is not None: last_rsi_values_to_consider = 5 analysed_rsi = rsi_v[-last_rsi_values_to_consider:] peak_reached = EvaluatorUtil.TrendAnalysis.min_has_just_been_reached(analysed_rsi, acceptance_window=0.95, delay=2) if peak_reached: price_weight, volume_weight = self._analyse_dip_weight(slow_rsi, fast_rsi, rsi_v[-1]) if price_weight is not None and volume_weight is not None: self.eval_note = { "price_weight": price_weight, "volume_weight": volume_weight, "current_candle_time": current_candle_time } await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) # bollinger_bands class BBMomentumEvaluator(evaluators.TAEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.period_length = 20 def init_user_inputs(self, inputs: dict) -> None: self.period_length = self.UI.user_input("period_length", enums.UserInputTypes.INT, self.period_length, inputs, min_val=1, title="Period: Bollinger bands period length.") async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, self.period_length, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(candle_data) >= self.period_length: # compute bollinger bands lower_band, middle_band, upper_band = tulipy.bbands(candle_data, self.period_length, 2) # if close to lower band => low value => bad, # therefore if close to middle, value is keeping up => good # finally if up the middle one or even close to the upper band => very good current_value = candle_data[-1] current_up = upper_band[-1] current_middle = middle_band[-1] current_low = lower_band[-1] delta_up = current_up - current_middle delta_low = current_middle - current_low # its exactly on all bands if current_up == current_low: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE # exactly on the middle elif current_value == current_middle: self.eval_note = 0 # up the upper band elif current_value > current_up: self.eval_note = 1 # down the lower band elif current_value < current_low: self.eval_note = -1 # regular values case: use parabolic factor all the time else: # up the middle band if current_middle < current_value: self.eval_note = math.pow((current_value - current_middle) / delta_up, 2) # down the middle band elif current_middle > current_value: self.eval_note = -1 * math.pow((current_middle - current_value) / delta_low, 2) await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) # EMA class EMAMomentumEvaluator(evaluators.TAEvaluator): PERIOD_LENGTH = "period_length" PRICE_THRESHOLD_PERCENT = "price_threshold_percent" REVERSE_SIGNAL = "reverse_signal" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.period_length = 21 self.price_threshold_percent = 2 self.price_threshold_multiplier = self.price_threshold_percent / 100 self.reverse_signal = False def init_user_inputs(self, inputs: dict) -> None: default_config = self.get_default_config() self.period_length = self.UI.user_input( self.PERIOD_LENGTH, enums.UserInputTypes.INT, default_config["period_length"], inputs, min_val=1, title="Period: Moving Average period length." ) self.price_threshold_percent = self.UI.user_input( self.PRICE_THRESHOLD_PERCENT, enums.UserInputTypes.FLOAT, default_config["price_threshold_percent"], inputs, min_val=0, title="Price threshold: Percent difference between the current price and current EMA value from " "which to trigger a long or short signal. " "Example with EMA value=200, Price threshold=5: a short signal will fire when price is above or " "equal to 210 and a long signal will when price is bellow or equal to 190", ) self.reverse_signal = self.UI.user_input( self.REVERSE_SIGNAL, enums.UserInputTypes.BOOLEAN, default_config["reverse_signal"], inputs, title="Reverse signal: when enabled, emits a short signal when the current price is bellow the EMA " "value and long signal when the current price is above the EMA value.", ) self.price_threshold_multiplier = self.price_threshold_percent / 100 @classmethod def get_default_config( cls, period_length: typing.Optional[int] = None, price_threshold_percent: typing.Optional[float] = None, reverse_signal: typing.Optional[bool] = False, ) -> dict: return { cls.PERIOD_LENGTH: period_length or 21, cls.PRICE_THRESHOLD_PERCENT: 2 if price_threshold_percent is None else price_threshold_percent, cls.REVERSE_SIGNAL: reverse_signal or False, } async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, self.period_length, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): self.eval_note = 0 if len(candle_data) >= self.period_length: # compute ema ema_values = tulipy.ema(candle_data, self.period_length) is_price_above_ema_threshold = candle_data[-1] >= (ema_values[-1] * (1 + self.price_threshold_multiplier)) is_price_bellow_ema_threshold = candle_data[-1] <= (ema_values[-1] * (1 - self.price_threshold_multiplier)) if is_price_above_ema_threshold: self.eval_note = 1 elif is_price_bellow_ema_threshold: self.eval_note = -1 if self.reverse_signal: self.eval_note = -1 * self.eval_note await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) # ADX --> trend_strength class ADXMomentumEvaluator(evaluators.TAEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.period_length = 14 def init_user_inputs(self, inputs: dict) -> None: self.period_length = self.UI.user_input("period_length", enums.UserInputTypes.INT, self.period_length, inputs, min_val=1, title="Period: ADX period length.") def _get_minimal_data(self): # 26 minimal_data length required for 14 period_length return self.period_length + 12 # implementation according to: https://www.investopedia.com/articles/technical/02/041002.asp => length = 14 and # exponential moving average = 20 in a uptrend market # idea: adx > 30 => strong trend, < 20 => trend change to come async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): symbol_candles = self.get_exchange_symbol_data(exchange, exchange_id, symbol) close_candles = trading_api.get_symbol_close_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) if len(close_candles) > self._get_minimal_data(): high_candles = trading_api.get_symbol_high_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) low_candles = trading_api.get_symbol_low_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, close_candles, high_candles, low_candles, candle) else: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) async def evaluate(self, cryptocurrency, symbol, time_frame, close_candles, high_candles, low_candles, candle): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(close_candles) >= self._get_minimal_data(): min_adx = 7.5 max_adx = 45 neutral_adx = 25 adx = tulipy.adx(high_candles, low_candles, close_candles, self.period_length) instant_ema = data_util.drop_nan(tulipy.ema(close_candles, 2)) slow_ema = data_util.drop_nan(tulipy.ema(close_candles, 20)) adx = data_util.drop_nan(adx) if len(adx): current_adx = adx[-1] current_slows_ema = slow_ema[-1] current_instant_ema = instant_ema[-1] multiplier = -1 if current_instant_ema < current_slows_ema else 1 # strong adx => strong trend if current_adx > neutral_adx: # if max adx already reached => when ADX forms a top and begins to turn down, you should look for a # retracement that causes the price to move toward its 20-day exponential moving average (EMA). adx_last_values = adx[-15:] adx_last_value = adx_last_values[-1] local_max_adx = adx_last_values.max() # max already reached => trend will slow down if adx_last_value < local_max_adx: self.eval_note = multiplier * (current_adx - neutral_adx) / (local_max_adx - neutral_adx) # max not reached => trend will continue, return chances to be max now else: crossing_indexes = EvaluatorUtil.TrendAnalysis.get_threshold_change_indexes(adx, neutral_adx) chances_to_be_max = \ EvaluatorUtil.TrendAnalysis.get_estimation_of_move_state_relatively_to_previous_moves_length( crossing_indexes, adx) if len(crossing_indexes) > 2 else 0.75 proximity_to_max = min(1, current_adx / max_adx) self.eval_note = multiplier * proximity_to_max * chances_to_be_max # weak adx => change to come else: self.eval_note = multiplier * min(1, ((neutral_adx - current_adx) / (neutral_adx - min_adx))) await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) class MACDMomentumEvaluator(evaluators.TAEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.previous_note = None self.long_period_length = 26 self.short_period_length = 12 self.signal_period_length = 9 def init_user_inputs(self, inputs: dict) -> None: self.short_period_length = self.UI.user_input( "short_period_length", enums.UserInputTypes.INT, self.short_period_length, inputs, min_val=1, title="MACD fast period length." ) self.long_period_length = self.UI.user_input( "long_period_length", enums.UserInputTypes.INT, self.long_period_length, inputs, min_val=1, title="MACD slow period length." ) self.signal_period_length = self.UI.user_input( "signal_period_length", enums.UserInputTypes.INT, self.signal_period_length, inputs, min_val=1, title="MACD signal period." ) def _analyse_pattern(self, pattern, macd_hist, zero_crossing_indexes, price_weight, pattern_move_time, sign_multiplier): # add pattern's strength weight = price_weight * EvaluatorUtil.PatternAnalyser.get_pattern_strength(pattern) average_pattern_period = 0.7 if len(zero_crossing_indexes) > 1: # compute chances to be after average pattern period patterns = [EvaluatorUtil.PatternAnalyser.get_pattern( macd_hist[zero_crossing_indexes[i]:zero_crossing_indexes[i + 1]]) for i in range(len(zero_crossing_indexes) - 1) ] if 0 != zero_crossing_indexes[0]: patterns.append(EvaluatorUtil.PatternAnalyser.get_pattern(macd_hist[0:zero_crossing_indexes[0]])) if len(macd_hist) - 1 != zero_crossing_indexes[-1]: patterns.append(EvaluatorUtil.PatternAnalyser.get_pattern(macd_hist[zero_crossing_indexes[-1]:])) double_patterns_count = patterns.count("W") + patterns.count("M") average_pattern_period = EvaluatorUtil.TrendAnalysis. \ get_estimation_of_move_state_relatively_to_previous_moves_length( zero_crossing_indexes, macd_hist, pattern_move_time, double_patterns_count) # if we have few data but wave is growing => set higher value if len(zero_crossing_indexes) <= 1 and price_weight == 1: if self.previous_note is not None: average_pattern_period = 0.95 self.previous_note = sign_multiplier * weight * average_pattern_period else: self.previous_note = None self.eval_note = sign_multiplier * weight * average_pattern_period async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(candle_data) > self.long_period_length: macd, macd_signal, macd_hist = tulipy.macd(candle_data, self.short_period_length, self.long_period_length, self.signal_period_length) # on macd hist => M pattern: bearish movement, W pattern: bullish movement # max on hist: optimal sell or buy macd_hist = data_util.drop_nan(macd_hist) zero_crossing_indexes = EvaluatorUtil.TrendAnalysis.get_threshold_change_indexes(macd_hist, 0) last_index = len(macd_hist) - 1 pattern, start_index, end_index = EvaluatorUtil.PatternAnalyser.find_pattern(macd_hist, zero_crossing_indexes, last_index) if pattern != EvaluatorUtil.PatternAnalyser.UNKNOWN_PATTERN: # set sign (-1 buy or 1 sell) sign_multiplier = -1 if pattern == "W" or pattern == "V" else 1 # set pattern time frame => W and M are on 2 time frames, others 1 pattern_move_time = 2 if (pattern == "W" or pattern == "M") and end_index == last_index else 1 # set weight according to the max value of the pattern and the current value current_pattern_start = start_index price_weight = macd_hist[-1] / macd_hist[current_pattern_start:].max() if sign_multiplier == 1 \ else macd_hist[-1] / macd_hist[current_pattern_start:].min() if not math.isnan(price_weight): self._analyse_pattern(pattern, macd_hist, zero_crossing_indexes, price_weight, pattern_move_time, sign_multiplier) await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) class KlingerOscillatorMomentumEvaluator(evaluators.TAEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.short_period = 35 # standard with klinger self.long_period = 55 # standard with klinger self.ema_signal_period = 13 # standard ema signal for klinger def init_user_inputs(self, inputs: dict) -> None: self.short_period = self.UI.user_input("short_period", enums.UserInputTypes.INT, self.short_period, inputs, min_val=1, title="Short period: length of the short klinger period (standard is 35).") self.long_period = self.UI.user_input("long_period", enums.UserInputTypes.INT, self.long_period, inputs, min_val=1, title="Long period: length of the long klinger period (standard is 55).") self.ema_signal_period = self.UI.user_input("ema_signal_period", enums.UserInputTypes.INT, self.ema_signal_period, inputs, min_val=1, title="Long period: length of the exponential moving average used " "to apply on the klinger results (standard is 13).") async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): symbol_candles = self.get_exchange_symbol_data(exchange, exchange_id, symbol) high_candles = trading_api.get_symbol_high_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) if len(high_candles) >= self.short_period: low_candles = trading_api.get_symbol_low_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) close_candles = trading_api.get_symbol_close_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) volume_candles = trading_api.get_symbol_volume_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, high_candles, low_candles, close_candles, volume_candles, candle) else: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) async def evaluate(self, cryptocurrency, symbol, time_frame, high_candles, low_candles, close_candles, volume_candles, candle): eval_proposition = commons_constants.START_PENDING_EVAL_NOTE kvo = tulipy.kvo(high_candles, low_candles, close_candles, volume_candles, self.short_period, self.long_period) kvo = data_util.drop_nan(kvo) if len(kvo) >= self.ema_signal_period: kvo_ema = tulipy.ema(kvo, self.ema_signal_period) ema_difference = kvo - kvo_ema if len(ema_difference) > 1: zero_crossing_indexes = EvaluatorUtil.TrendAnalysis.get_threshold_change_indexes(ema_difference, 0) current_difference = ema_difference[-1] significant_move_threshold = numpy.std(ema_difference) factor = 0.2 if EvaluatorUtil.TrendAnalysis.peak_has_been_reached_already( ema_difference[zero_crossing_indexes[-1]:]): if abs(current_difference) > significant_move_threshold: factor = 1 else: factor = 0.5 eval_proposition = current_difference * factor / significant_move_threshold if abs(eval_proposition) > 1: eval_proposition = 1 if eval_proposition > 0 else -1 self.eval_note = eval_proposition await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) class KlingerOscillatorReversalConfirmationMomentumEvaluator(evaluators.TAEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.short_period = 35 # standard with klinger self.long_period = 55 # standard with klinger self.ema_signal_period = 13 # standard ema signal for klinger def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.short_period = self.UI.user_input("short_period", enums.UserInputTypes.INT, self.short_period, inputs, min_val=1, title="Short period: length of the short klinger period (standard is 35).") self.long_period = self.UI.user_input("long_period", enums.UserInputTypes.INT, self.long_period, inputs, min_val=1, title="Long period: length of the long klinger period (standard is 55).") self.ema_signal_period = self.UI.user_input("ema_signal_period", enums.UserInputTypes.INT, self.ema_signal_period, inputs, min_val=1, title="Long period: length of the exponential moving average used " "to apply on the klinger results (standard is 13).") @staticmethod def get_eval_type(): return bool async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): symbol_candles = self.get_exchange_symbol_data(exchange, exchange_id, symbol) high_candles = trading_api.get_symbol_high_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) if len(high_candles) >= self.short_period: low_candles = trading_api.get_symbol_low_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) close_candles = trading_api.get_symbol_close_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) volume_candles = trading_api.get_symbol_volume_candles(symbol_candles, time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, high_candles, low_candles, close_candles, volume_candles, candle) else: self.eval_note = False await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) async def evaluate(self, cryptocurrency, symbol, time_frame, high_candles, low_candles, close_candles, volume_candles, candle): if len(high_candles) >= self.short_period: kvo = tulipy.kvo(high_candles, low_candles, close_candles, volume_candles, self.short_period, self.long_period) kvo = data_util.drop_nan(kvo) if len(kvo) >= self.ema_signal_period: kvo_ema = tulipy.ema(kvo, self.ema_signal_period) ema_difference = kvo - kvo_ema if len(ema_difference) > 1: zero_crossing_indexes = EvaluatorUtil.TrendAnalysis.get_threshold_change_indexes(ema_difference, 0) max_elements = 7 to_consider_kvo = min(max_elements, len(ema_difference) - zero_crossing_indexes[-1]) self.eval_note = EvaluatorUtil.TrendAnalysis.min_has_just_been_reached( ema_difference[-to_consider_kvo:], acceptance_window=0.9, delay=1) await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/ADXMomentumEvaluator.md ================================================ Uses the [Average Directional Index](https://www.investopedia.com/terms/a/adx.asp) to find reversals. The default implementation is according to [Investopedia's ADX: The Trend Strength Indicator](https://www.investopedia.com/articles/technical/02/041002.asp). Evaluates -1 to 1 according to the current price using the [Exponential Moving Average](https://www.investopedia.com/terms/e/ema.asp) with a length of 20 coupled with the [ADX](https://www.investopedia.com/terms/a/adx.asp). ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/BBMomentumEvaluator.md ================================================ Uses the [Bollinger bands](https://www.investopedia.com/terms/b/bollingerbands.asp) to evaluate a value from -1 to 1 according to the current price distance from to the [Bollinger bands](https://www.investopedia.com/terms/b/bollingerbands.asp) values. ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/EMAMomentumEvaluator.md ================================================ Uses [exponential moving averages](https://www.investopedia.com/terms/m/movingaverage.asp) to find signal when the current price exceeds the average value. Evaluates -1 or 1 when the current price is far enough from the EMA. ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/KlingerOscillatorMomentumEvaluator.md ================================================ Uses [Klinger Oscillator](https://www.investopedia.com/terms/k/klingeroscillator.asp) to find reversals. Evaluates -1 to 1 using [Klinger](https://www.investopedia.com/terms/k/klingeroscillator.asp) reversal estimation ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/KlingerOscillatorReversalConfirmationMomentumEvaluator.md ================================================ Uses [Klinger Oscillator](https://www.investopedia.com/terms/k/klingeroscillator.asp) to find reversals. Returns True on reversal confirmation. ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/MACDMomentumEvaluator.md ================================================ Uses the [Moving Average Convergence Divergence](https://www.investopedia.com/terms/m/macd.asp) to find reversals. This evaluator will try to find patterns in the [MACD](https://www.investopedia.com/terms/m/macd.asp) histogram and returns -1 to 1 according to the price and identified pattern strength. ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/RSIMomentumEvaluator.md ================================================ Uses the [Relative Strength Index](https://www.investopedia.com/terms/r/rsi.asp) to find trend reversals. When found, evaluates -1 to 1 according to the strength of the [RSI](https://www.investopedia.com/terms/r/rsi.asp). ================================================ FILE: Evaluator/TA/momentum_evaluator/resources/RSIWeightMomentumEvaluator.md ================================================ Uses the [Relative Strength Index](https://www.investopedia.com/terms/r/rsi.asp) to find dips and give them weight according to the trend ================================================ FILE: Evaluator/TA/momentum_evaluator/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Evaluator/TA/momentum_evaluator/tests/test_adx_momentum_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import tests.functional_tests.evaluators_tests.abstract_TA_test as abstract_TA_test import tentacles.Evaluator.TA as TA # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def evaluator_tester(): evaluator_tester_instance = TestADXTAEvaluator() evaluator_tester_instance.TA_evaluator_class = TA.ADXMomentumEvaluator return evaluator_tester_instance class TestADXTAEvaluator(abstract_TA_test.AbstractTATest): @staticmethod async def test_stress_test(evaluator_tester): await evaluator_tester.run_stress_test_without_exceptions(0.7) @staticmethod async def test_reactions_to_dump(evaluator_tester): await evaluator_tester.run_test_reactions_to_dump(0.2, 0.35, -0.2, -0.1, 0) @staticmethod async def test_reactions_to_pump(evaluator_tester): await evaluator_tester.run_test_reactions_to_pump(0, 0.1, 0.45, 0.7, 0.6, 0.65, 0.75) @staticmethod async def test_reaction_to_rise_after_over_sold(evaluator_tester): await evaluator_tester.run_test_reactions_to_rise_after_over_sold(0.8, -0.1, -0.5, -0.52, 0.8) @staticmethod async def test_reaction_to_over_bought_then_dip(evaluator_tester): await evaluator_tester.run_test_reactions_to_over_bought_then_dip(0.1, 0.1, 0.3, 0.4, -0.4, 0.2) @staticmethod async def test_reaction_to_flat_trend(evaluator_tester): await evaluator_tester.run_test_reactions_to_flat_trend( # eval_start_move_ending_up_in_a_rise, 0.4, # eval_reaches_flat_trend, eval_first_micro_up_p1, eval_first_micro_up_p2, 0.1, 0.4, 0.45, # eval_micro_down1, eval_micro_up1, eval_micro_down2, eval_micro_up2, 1, 0.6, 0.1, 0.4, # eval_micro_down3, eval_back_normal3, eval_micro_down4, eval_back_normal4, -0.4, 0.5, -0.7, 0.8, # eval_micro_down5, eval_back_up5, eval_micro_up6, eval_back_down6, -0.1, -0.5, 0.25, 0.35, # eval_back_normal6, eval_micro_down7, eval_back_up7, eval_micro_down8, 0.3, -0.5, -0.6, -0.45, # eval_back_up8, eval_micro_down9, eval_back_up9 -0.35, -0.1, 0.1) ================================================ FILE: Evaluator/TA/momentum_evaluator/tests/test_bollinger_bands_momentum_TA_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import tests.functional_tests.evaluators_tests.abstract_TA_test as abstract_TA_test import tentacles.Evaluator.TA as TA # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def evaluator_tester(): evaluator_tester_instance = TestBollingerBandsMomentumeEvaluator() evaluator_tester_instance.TA_evaluator_class = TA.BBMomentumEvaluator return evaluator_tester_instance class TestBollingerBandsMomentumeEvaluator(abstract_TA_test.AbstractTATest): @staticmethod async def test_stress_test(evaluator_tester): await evaluator_tester.run_stress_test_without_exceptions() @staticmethod async def test_reactions_to_dump(evaluator_tester): await evaluator_tester.run_test_reactions_to_dump(0.7, 0.2, -1, -1, -1) @staticmethod async def test_reactions_to_pump(evaluator_tester): await evaluator_tester.run_test_reactions_to_pump(0.4, 0.5, 1, 1, 1, 1, 0.1) @staticmethod async def test_reaction_to_rise_after_over_sold(evaluator_tester): await evaluator_tester.run_test_reactions_to_rise_after_over_sold(-0.1, -0.99, -0.99, -0.5, 1) @staticmethod async def test_reaction_to_over_bought_then_dip(evaluator_tester): await evaluator_tester.run_test_reactions_to_over_bought_then_dip(0, 1, 1, 0.95, -0.3, -0.1) @staticmethod async def test_reaction_to_flat_trend(evaluator_tester): await evaluator_tester.run_test_reactions_to_flat_trend( # eval_start_move_ending_up_in_a_rise, 1, # eval_reaches_flat_trend, eval_first_micro_up_p1, eval_first_micro_up_p2, 1, 0.8, 0.4, # eval_micro_down1, eval_micro_up1, eval_micro_down2, eval_micro_up2, 0.1, 1, -0.3, 0.1, # eval_micro_down3, eval_back_normal3, eval_micro_down4, eval_back_normal4, -0.6, 0.5, 0, 0.5, # eval_micro_down5, eval_back_up5, eval_micro_up6, eval_back_down6, -1, -0.15, 1, 0.1, # eval_back_normal6, eval_micro_down7, eval_back_up7, eval_micro_down8, 0.4, -0.1, 0, -1, # eval_back_up8, eval_micro_down9, eval_back_up9 -0.05, -1, 0.5) ================================================ FILE: Evaluator/TA/momentum_evaluator/tests/test_klinger_TA_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import tests.functional_tests.evaluators_tests.abstract_TA_test as abstract_TA_test import tentacles.Evaluator.TA as TA # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def evaluator_tester(): evaluator_tester_instance = TestKlingerEvaluator() evaluator_tester_instance.TA_evaluator_class = TA.KlingerOscillatorMomentumEvaluator return evaluator_tester_instance class TestKlingerEvaluator(abstract_TA_test.AbstractTATest): @staticmethod async def test_stress_test(evaluator_tester): await evaluator_tester.run_stress_test_without_exceptions(0.7, False, skip_long_time_frames=True) @staticmethod async def test_reactions_to_dump(evaluator_tester): await evaluator_tester.run_test_reactions_to_dump(0, 0, -0.2, -0.4, -0.55) @staticmethod async def test_reactions_to_pump(evaluator_tester): await evaluator_tester.run_test_reactions_to_pump(-0.1, -0.1, 0, 0.1, 0.2, 0, -0.5) @staticmethod async def test_reaction_to_rise_after_over_sold(evaluator_tester): await evaluator_tester.run_test_reactions_to_rise_after_over_sold(-0.2, -0.6, -1, -1, 0.1) @staticmethod async def test_reaction_to_over_bought_then_dip(evaluator_tester): await evaluator_tester.run_test_reactions_to_over_bought_then_dip(-1, 0, 0.5, 0.5, -0.8, -1) @staticmethod async def test_reaction_to_flat_trend(evaluator_tester): await evaluator_tester.run_test_reactions_to_flat_trend( # eval_start_move_ending_up_in_a_rise, 0.9, # eval_reaches_flat_trend, eval_first_micro_up_p1, eval_first_micro_up_p2, 0.7, 0.55, 0.3, # eval_micro_down1, eval_micro_up1, eval_micro_down2, eval_micro_up2, -0.3, -0.25, -0.4, -0.1, # eval_micro_down3, eval_back_normal3, eval_micro_down4, eval_back_normal4, 0, -0.1, 0.1, 0.1, # eval_micro_down5, eval_back_up5, eval_micro_up6, eval_back_down6, -0.1, -0.1, 0.1, 0.25, # eval_back_normal6, eval_micro_down7, eval_back_up7, eval_micro_down8, 0, 0.1, -0.2, 0, # eval_back_up8, eval_micro_down9, eval_back_up9 0, 0, 0.1) ================================================ FILE: Evaluator/TA/momentum_evaluator/tests/test_macd_TA_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import tests.functional_tests.evaluators_tests.abstract_TA_test as abstract_TA_test import tentacles.Evaluator.TA as TA # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def evaluator_tester(): evaluator_tester_instance = TestMACDEvaluator() evaluator_tester_instance.TA_evaluator_class = TA.MACDMomentumEvaluator return evaluator_tester_instance class TestMACDEvaluator(abstract_TA_test.AbstractTATest): @staticmethod async def test_stress_test(evaluator_tester): await evaluator_tester.run_stress_test_without_exceptions(0.6) @staticmethod async def test_reactions_to_dump(evaluator_tester): await evaluator_tester.run_test_reactions_to_dump(0.3, 0.25, -0.15, -0.3, -0.5) @staticmethod async def test_reactions_to_pump(evaluator_tester): await evaluator_tester.run_test_reactions_to_pump(0.3, 0.4, 0.75, 0.75, 0.75, 0.75, 0.2) @staticmethod async def test_reaction_to_rise_after_over_sold(evaluator_tester): await evaluator_tester.run_test_reactions_to_rise_after_over_sold(0, -0.5, -0.65, -0.4, -0.08) @staticmethod async def test_reaction_to_over_bought_then_dip(evaluator_tester): await evaluator_tester.run_test_reactions_to_over_bought_then_dip(-0.6, 0.1, 0.6, 0.7, -0.35, -0.65) @staticmethod async def test_reaction_to_flat_trend(evaluator_tester): await evaluator_tester.run_test_reactions_to_flat_trend( # eval_start_move_ending_up_in_a_rise, 0.75, # eval_reaches_flat_trend, eval_first_micro_up_p1, eval_first_micro_up_p2, 0.6, 0.7, 0.45, # eval_micro_down1, eval_micro_up1, eval_micro_down2, eval_micro_up2, -0.1, -0.6, -0.55, -0.4, # eval_micro_down3, eval_back_normal3, eval_micro_down4, eval_back_normal4, -0.25, -0.1, -0.1, 0.2, # eval_micro_down5, eval_back_up5, eval_micro_up6, eval_back_down6, -0.5, -0.6, 0.24, 0.35, # eval_back_normal6, eval_micro_down7, eval_back_up7, eval_micro_down8, 0.49, -0.1, -0.4, -0.26, # eval_back_up8, eval_micro_down9, eval_back_up9 -0.31, -0.7, 0.1) ================================================ FILE: Evaluator/TA/momentum_evaluator/tests/test_rsi_TA_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import tests.functional_tests.evaluators_tests.abstract_TA_test as abstract_TA_test import tentacles.Evaluator.TA as TA # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def evaluator_tester(): evaluator_tester_instance = TestRSIEvaluator() evaluator_tester_instance.TA_evaluator_class = TA.RSIMomentumEvaluator return evaluator_tester_instance class TestRSIEvaluator(abstract_TA_test.AbstractTATest): @staticmethod async def test_stress_test(evaluator_tester): await evaluator_tester.run_stress_test_without_exceptions(0.7, False) @staticmethod async def test_reactions_to_dump(evaluator_tester): await evaluator_tester.run_test_reactions_to_dump(0.3, -0.2, -0.8, -1, -1) @staticmethod async def test_reactions_to_pump(evaluator_tester): await evaluator_tester.run_test_reactions_to_pump(0.3, 0.6, 1, 1, 1, 1, 0.5) @staticmethod async def test_reaction_to_rise_after_over_sold(evaluator_tester): await evaluator_tester.run_test_reactions_to_rise_after_over_sold(-1, -1, -1, -1, -0.7) @staticmethod async def test_reaction_to_over_bought_then_dip(evaluator_tester): await evaluator_tester.run_test_reactions_to_over_bought_then_dip(0.1, 0.4, 0.85, 1, 0.75, 0.8) @staticmethod async def test_reaction_to_flat_trend(evaluator_tester): await evaluator_tester.run_test_reactions_to_flat_trend( # eval_start_move_ending_up_in_a_rise, 0.4, # eval_reaches_flat_trend, eval_first_micro_up_p1, eval_first_micro_up_p2, 0.55, 0.9, 1, # eval_micro_down1, eval_micro_up1, eval_micro_down2, eval_micro_up2, 0.5, 0.8, 1, 0.7, # eval_micro_down3, eval_back_normal3, eval_micro_down4, eval_back_normal4, 0.55, -0.1, 0.75, 0, # eval_micro_down5, eval_back_up5, eval_micro_up6, eval_back_down6, 0.2, -0.6, -0.45, 0.1, # eval_back_normal6, eval_micro_down7, eval_back_up7, eval_micro_down8, 0, 0.75, 0.25, 0, # eval_back_up8, eval_micro_down9, eval_back_up9 -1, -1, -0.75) ================================================ FILE: Evaluator/TA/trend_evaluator/__init__.py ================================================ from .trend import DoubleMovingAverageTrendEvaluator, EMADivergenceTrendEvaluator, DeathAndGoldenCrossEvaluator, SuperTrendEvaluator ================================================ FILE: Evaluator/TA/trend_evaluator/config/DeathAndGoldenCrossEvaluator.json ================================================ { "fast_length": 50, "slow_length": 200, "slow_ma_type": "SMA", "fast_ma_type": "SMA" } ================================================ FILE: Evaluator/TA/trend_evaluator/config/DoubleMovingAverageTrendEvaluator.json ================================================ { "long_period_length": 10, "short_period_length": 5 } ================================================ FILE: Evaluator/TA/trend_evaluator/config/EMADivergenceTrendEvaluator.json ================================================ { "size": 50, "short": -2, "long": 2 } ================================================ FILE: Evaluator/TA/trend_evaluator/config/SuperTrendEvaluator.json ================================================ { "factor": 3, "length": 10 } ================================================ FILE: Evaluator/TA/trend_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["DoubleMovingAverageTrendEvaluator", "EMADivergenceTrendEvaluator", "DeathAndGoldenCrossEvaluator", "SuperTrendEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/TA/trend_evaluator/resources/DeathAndGoldenCrossEvaluator.md ================================================ DeathAndGoldenCrossEvaluator is based on two [moving averages](https://www.investopedia.com/terms/m/movingaverage.asp), by default one of **50** periods and other one of **200**. If the fast moving average is above the slow moving average, this indicates a bull market (signal: -1) When this happens it's called a [Golden Cross](https://www.investopedia.com/terms/g/goldencross.asp). Inversely, if it's the fast moving average which is above the slow moving average this indicates a bear market (signal: 1). When this happens it's called a [Death Cross](https://www.investopedia.com/terms/d/deathcross.asp) This evaluator will always produce a value of `0` except right after a golden or death cross is found, in this case a `-1` or `1` value will be produced. ================================================ FILE: Evaluator/TA/trend_evaluator/resources/DoubleMovingAverageTrendEvaluator.md ================================================ Uses two [moving averages](https://www.investopedia.com/terms/m/movingaverage.asp) (a slow and a fast one) to find reversals. Evaluates from -1 to 1 relatively to the computed reversal probability and the current price distance from [moving averages](https://www.investopedia.com/terms/m/movingaverage.asp). ================================================ FILE: Evaluator/TA/trend_evaluator/resources/EMADivergenceTrendEvaluator.md ================================================ Uses [exponential moving averages](https://www.investopedia.com/terms/e/ema.asp) to find price divergences. Evaluates from -1 to 1 relatively to the computed divergence strength. ================================================ FILE: Evaluator/TA/trend_evaluator/resources/SuperTrendEvaluator.md ================================================ SuperTrendEvaluator is a trend-following indicator based on Average True Range [ATR](https://www.tradingview.com/scripts/averagetruerange/). The calculation of its single line combines trend detection and volatility. It can be used to detect changes in trend direction and to position stops. Evaluates -1 on an upwards trend and 1 if the trend is downwards. ================================================ FILE: Evaluator/TA/trend_evaluator/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Evaluator/TA/trend_evaluator/tests/test_double_moving_averages_TA_evaluator.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio from tests.functional_tests.evaluators_tests.abstract_TA_test import AbstractTATest from tentacles.Evaluator.TA import DoubleMovingAverageTrendEvaluator # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def evaluator_tester(): evaluator_tester_instance = TestDoubleMovingAveragesEvaluator() evaluator_tester_instance.TA_evaluator_class = DoubleMovingAverageTrendEvaluator return evaluator_tester_instance class TestDoubleMovingAveragesEvaluator(AbstractTATest): @staticmethod async def test_stress_test(evaluator_tester): await evaluator_tester.run_stress_test_without_exceptions(0.8) @staticmethod async def test_reactions_to_dump(evaluator_tester): await evaluator_tester.run_test_reactions_to_dump(0.15, 0.15, -0.35, -0.75, -1) @staticmethod async def test_reactions_to_pump(evaluator_tester): await evaluator_tester.run_test_reactions_to_pump(0.1, 0.4, 1, 1, 1, 0.96, -0.45) @staticmethod async def test_reaction_to_rise_after_over_sold(evaluator_tester): await evaluator_tester.run_test_reactions_to_rise_after_over_sold(-0.7, -0.99, -0.99, -0.5, 0.85) @staticmethod async def test_reaction_to_over_bought_then_dip(evaluator_tester): await evaluator_tester.run_test_reactions_to_over_bought_then_dip(0, 0.4, 0.7, 0.6, -0.88, -0.1) @staticmethod async def test_reaction_to_flat_trend(evaluator_tester): await evaluator_tester.run_test_reactions_to_flat_trend( # eval_start_move_ending_up_in_a_rise, 0.45, # eval_reaches_flat_trend, eval_first_micro_up_p1, eval_first_micro_up_p2, 1, 0.65, 0.2, # eval_micro_down1, eval_micro_up1, eval_micro_down2, eval_micro_up2, -0.25, 0, -0.1, 0, # eval_micro_down3, eval_back_normal3, eval_micro_down4, eval_back_normal4, -0.1, 0, -0.1, 0, # eval_micro_down5, eval_back_up5, eval_micro_up6, eval_back_down6, 0.2, -0.10, 0, 0.1, # eval_back_normal6, eval_micro_down7, eval_back_up7, eval_micro_down8, -0.05, -0.1, -0.1, -0.15, # eval_back_up8, eval_micro_down9, eval_back_up9 0, -0.1, 0.1) ================================================ FILE: Evaluator/TA/trend_evaluator/trend.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import math import tulipy import numpy import octobot_commons.constants as commons_constants import octobot_commons.enums as enums import octobot_commons.data_util as data_util import octobot_evaluators.evaluators as evaluators import octobot_evaluators.util as evaluators_util import octobot_trading.api as trading_api import tentacles.Evaluator.Util as EvaluatorUtil class SuperTrendEvaluator(evaluators.TAEvaluator): FACTOR = "factor" LENGTH = "length" PREV_UPPER_BAND = "prev_upper_band" PREV_LOWER_BAND = "prev_lower_band" PREV_SUPERTREND = "prev_supertrend" PREV_ATR = "prev_atr" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.factor = 3 self.length = 10 self.reversals_only = False self.eval_note = commons_constants.START_PENDING_EVAL_NOTE self.previous_value = {} def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the evaluator, should define all the evaluator's user inputs """ self.factor = self.UI.user_input("factor", enums.UserInputTypes.FLOAT, self.factor, inputs, min_val=0, title="Factor multiplier of the ATR") self.length = self.UI.user_input("length", enums.UserInputTypes.INT, self.length, inputs, min_val=1, title="Length of the ATR") self.reversals_only = self.UI.user_input( "reversals_only", enums.UserInputTypes.BOOLEAN, self.reversals_only, inputs, title="Reversals only: evaluates -1 and 1 only on trend reversals, 0 otherwise" ) async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): exchange_symbol_data = self.get_exchange_symbol_data(exchange, exchange_id, symbol) high = trading_api.get_symbol_high_candles(exchange_symbol_data, time_frame, include_in_construction=inc_in_construction_data) low = trading_api.get_symbol_low_candles(exchange_symbol_data, time_frame, include_in_construction=inc_in_construction_data) close = trading_api.get_symbol_close_candles(exchange_symbol_data, time_frame, include_in_construction=inc_in_construction_data) self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(close) > self.length: await self.evaluate(cryptocurrency, symbol, time_frame, candle, high, low, close) await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) async def evaluate(self, cryptocurrency, symbol, time_frame, candle, high, low, close): hl2 = EvaluatorUtil.CandlesUtil.HL2(high, low)[-1] atr = tulipy.atr(high, low, close, self.length)[-1] previous_value = self.get_previous_value(symbol, time_frame) upper_band = hl2 + self.factor * atr lower_band = hl2 - self.factor * atr prev_upper_band = previous_value.get(self.PREV_UPPER_BAND, 0) prev_lower_band = previous_value.get(self.PREV_LOWER_BAND, 0) # compute latest lower and upper band values latest_lower_band = lower_band if (lower_band > prev_lower_band or close[-2] < prev_lower_band) else prev_lower_band latest_upper_band = upper_band if (upper_band < prev_upper_band or close[-2] > prev_upper_band) else prev_upper_band prev_super_trend = previous_value.get(self.PREV_SUPERTREND, 0) signal = -1 is_reversal = False if previous_value.get(self.PREV_ATR, None) is None: # not enough data to compute supertrend evaluation signal = -1 else: # there is a previous value: check if the latest close is above or below ATR # and select the correct band to use if prev_super_trend == prev_upper_band: # previous bearish trend: previous super trend used the upper band # bullish if the latest close is above latest upper band bullish_switch = close[-1] > latest_upper_band if bullish_switch: # bullish switch of the trend signal = -1 is_reversal = True else: # bearish continuation of the trend signal = 1 else: # previous bullish trend: previous super trend used the lower band # bearsish if the latest close is bellow latest lower band bearish_switch = close[-1] < latest_lower_band if bearish_switch: # bearish switch of the trend signal = 1 is_reversal = True else: # bullish continuation of the trend signal = -1 previous_value[self.PREV_ATR] = atr previous_value[self.PREV_UPPER_BAND] = latest_upper_band previous_value[self.PREV_LOWER_BAND] = latest_lower_band # store the latest used super trend band: bullish = lower band, bearish = upper band previous_value[self.PREV_SUPERTREND] = latest_lower_band if signal == -1 else latest_upper_band self.eval_note = signal if is_reversal or not self.reversals_only else commons_constants.START_PENDING_EVAL_NOTE def get_previous_value(self, symbol, time_frame): try: previous_symbol_value = self.previous_value[symbol] except KeyError: self.previous_value[symbol] = {} previous_symbol_value = self.previous_value[symbol] try: return previous_symbol_value[time_frame] except KeyError: previous_symbol_value[time_frame] = {} return previous_symbol_value[time_frame] class DeathAndGoldenCrossEvaluator(evaluators.TAEvaluator): FAST_LENGTH = "fast_length" SLOW_LENGTH = "slow_length" SLOW_MA_TYPE = "slow_ma_type" FAST_MA_TYPE = "fast_ma_type" MA_TYPES = ["EMA", "WMA", "SMA", "LSMA", "KAMA", "DEMA", "TEMA", "VWMA"] def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.fast_length = 50 self.slow_length = 200 self.fast_ma_type = "sma" self.slow_ma_type = "sma" self.eval_note = commons_constants.START_PENDING_EVAL_NOTE def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the evaluator, should define all the evaluator's user inputs """ self.fast_length = self.UI.user_input(self.FAST_LENGTH, enums.UserInputTypes.INT, self.fast_length, inputs, min_val=1, title="Fast MA length") self.slow_length = self.UI.user_input(self.SLOW_LENGTH, enums.UserInputTypes.INT, self.slow_length, inputs, min_val=1, title="Slow MA length") self.fast_ma_type = self.UI.user_input(self.FAST_MA_TYPE, enums.UserInputTypes.OPTIONS, self.fast_ma_type, inputs, options=self.MA_TYPES, title="Fast MA type").lower() self.slow_ma_type = self.UI.user_input(self.SLOW_MA_TYPE, enums.UserInputTypes.OPTIONS, self.slow_ma_type, inputs, options=self.MA_TYPES, title="Slow MA type").lower() async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): close = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) volume = trading_api.get_symbol_volume_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(close) > max(self.slow_length, self.fast_length): await self.evaluate(cryptocurrency, symbol, time_frame, candle, close, volume) await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) async def evaluate(self, cryptocurrency, symbol, time_frame, candle, candle_data, volume_data): if self.fast_ma_type == "vwma": fast_ma = tulipy.vwma(candle_data, volume_data, self.fast_length) elif self.fast_ma_type == "lsma": fast_ma = tulipy.linreg(candle_data, self.fast_length) else: fast_ma = getattr(tulipy, self.fast_ma_type)(candle_data, self.fast_length) if self.slow_ma_type == "vwma": slow_ma = tulipy.vwma(candle_data, volume_data, self.slow_length) elif self.slow_ma_type == "lsma": slow_ma = tulipy.linreg(candle_data, self.slow_length) else: slow_ma = getattr(tulipy, self.slow_ma_type)(candle_data, self.slow_length) if min(len(fast_ma), len(slow_ma)) < 2: # can't compute crosses: not enough data self.logger.debug(f"Not enough data to compute crosses, skipping {symbol} {time_frame} evaluation") return just_crossed = ( fast_ma[-1] > slow_ma[-1] and fast_ma[-2] < slow_ma[-2] ) or ( fast_ma[-1] < slow_ma[-1] and fast_ma[-2] > slow_ma[-2] ) if just_crossed: # crosses happen when the fast_ma and fast_ma just crossed, therefore when it happened on the last candle if fast_ma[-1] > slow_ma[-1]: # golden cross self.eval_note = -1 elif fast_ma[-1] < slow_ma[-1]: # death cross self.eval_note = 1 # evaluates position of the current (2 unit) average trend relatively to the 5 units average and 10 units average trend class DoubleMovingAverageTrendEvaluator(evaluators.TAEvaluator): def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.slow_period_length = 10 self.fast_period_length = 5 def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the evaluator, should define all the evaluator's user inputs """ self.slow_period_length = self.UI.user_input("long_period_length", enums.UserInputTypes.INT, self.slow_period_length, inputs, min_val=1, title="Slow SMA length") self.fast_period_length = self.UI.user_input("short_period_length", enums.UserInputTypes.INT, self.fast_period_length, inputs, min_val=1, title="Fast SMA length") async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(candle_data) >= max(self.slow_period_length, self.fast_period_length): current_moving_average = tulipy.sma(candle_data, 2) results = [self.get_moving_average_analysis(candle_data, current_moving_average, time_unit) for time_unit in (self.fast_period_length, self.slow_period_length)] if len(results): self.eval_note = numpy.mean(results) else: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if self.eval_note == 0: self.eval_note = commons_constants.START_PENDING_EVAL_NOTE await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) # < 0 --> Current average bellow other one (computed using time_period) # > 0 --> Current average above other one (computed using time_period) @staticmethod def get_moving_average_analysis(data, current_moving_average, time_period): time_period_unit_moving_average = tulipy.sma(data, time_period) # equalize array size min_len_arrays = min(len(time_period_unit_moving_average), len(current_moving_average)) # compute difference between 1 unit values and others ( >0 means currently up the other one) values_difference = \ (current_moving_average[-min_len_arrays:] - time_period_unit_moving_average[-min_len_arrays:]) values_difference = data_util.drop_nan(values_difference) if len(values_difference): # indexes where current_unit_moving_average crosses time_period_unit_moving_average crossing_indexes = EvaluatorUtil.TrendAnalysis.get_threshold_change_indexes(values_difference, 0) multiplier = 1 if values_difference[-1] > 0 else -1 # check at least some data crossed 0 if crossing_indexes: normalized_data = data_util.normalize_data(values_difference) current_value = min(abs(normalized_data[-1]) * 2, 1) if math.isnan(current_value): return 0 # check <= values_difference.count()-1if current value is max/min if current_value == 0 or current_value == 1: chances_to_be_max = EvaluatorUtil.TrendAnalysis.get_estimation_of_move_state_relatively_to_previous_moves_length( crossing_indexes, values_difference) return multiplier * current_value * chances_to_be_max # other case: maxima already reached => return distance to max else: return multiplier * current_value # just crossed the average => neutral return 0 # evaluates position of the current ema to detect divergences class EMADivergenceTrendEvaluator(evaluators.TAEvaluator): EMA_SIZE = "size" SHORT_VALUE = "short" LONG_VALUE = "long" def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.period = 50 self.long_value = 2 self.short_value = -2 def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the evaluator, should define all the evaluator's user inputs """ self.period = self.UI.user_input(self.EMA_SIZE, enums.UserInputTypes.INT, self.period, inputs, min_val=1, title="EMA period length") self.long_value = self.UI.user_input("long_value", enums.UserInputTypes.INT, self.long_value, inputs, title="Long threshold: Minimum % price difference from EMA " "consider a long signal. Should be positive in most cases") self.short_value = self.UI.user_input("short_value", enums.UserInputTypes.INT, self.short_value, inputs, title="Short threshold: Minimum % price difference from EMA " "consider a short signal. Should be negative in most cases") async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): self.eval_note = commons_constants.START_PENDING_EVAL_NOTE if len(candle_data) >= self.period: current_ema = tulipy.ema(candle_data, self.period)[-1] current_price_close = candle_data[-1] diff = (current_price_close / current_ema * 100) - 100 if diff <= self.long_value: self.eval_note = -1 elif diff >= self.short_value: self.eval_note = 1 await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) ================================================ FILE: Evaluator/TA/volatility_evaluator/__init__.py ================================================ from .volatility import StochasticRSIVolatilityEvaluator ================================================ FILE: Evaluator/TA/volatility_evaluator/config/StochasticRSIVolatilityEvaluator.json ================================================ { "period": 14, "low_level": 1, "high_level": 98 } ================================================ FILE: Evaluator/TA/volatility_evaluator/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["StochasticRSIVolatilityEvaluator"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/TA/volatility_evaluator/resources/StochasticRSIVolatilityEvaluator.md ================================================ Uses the [Stochastic RSI](https://www.investopedia.com/terms/s/stochrsi.asp) as a volatilty evaluator to identify trends. When found, evaluates from -1 to 1 according to the strength of the trend. ================================================ FILE: Evaluator/TA/volatility_evaluator/volatility.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tulipy import octobot_commons.constants as commons_constants import octobot_commons.enums as enums import octobot_commons.data_util as data_util import octobot_evaluators.evaluators as evaluators import octobot_evaluators.util as evaluators_util import octobot_trading.api as trading_api class StochasticRSIVolatilityEvaluator(evaluators.TAEvaluator): STOCHRSI_PERIOD = "period" HIGH_LEVEL = "high_level" LOW_LEVEL = "low_level" TULIPY_INDICATOR_MULTIPLICATOR = 100 def __init__(self, tentacles_setup_config): super().__init__(tentacles_setup_config) self.period = 14 self.low_level = 1 self.high_level = 98 def init_user_inputs(self, inputs: dict) -> None: self.period = self.UI.user_input(self.STOCHRSI_PERIOD, enums.UserInputTypes.INT, self.period, inputs, min_val=2, title="Period: length of the stochastic RSI period.") self.low_level = self.UI.user_input(self.LOW_LEVEL, enums.UserInputTypes.FLOAT, self.low_level, inputs, min_val=0, title="Low threshold: stochastic RSI level from which evaluation " "is considered a buy signal.") self.high_level = self.UI.user_input(self.HIGH_LEVEL, enums.UserInputTypes.FLOAT, self.high_level, inputs, min_val=0, title="High threshold: stochastic RSI level from which evaluation " "is considered a sell signal.") async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle, inc_in_construction_data): candle_data = trading_api.get_symbol_close_candles(self.get_exchange_symbol_data(exchange, exchange_id, symbol), time_frame, include_in_construction=inc_in_construction_data) await self.evaluate(cryptocurrency, symbol, time_frame, candle_data, candle) async def evaluate(self, cryptocurrency, symbol, time_frame, candle_data, candle): try: if len(candle_data) >= self.period * 2: stochrsi_value = tulipy.stochrsi(data_util.drop_nan(candle_data), self.period)[-1] if stochrsi_value * self.TULIPY_INDICATOR_MULTIPLICATOR >= self.high_level: self.eval_note = 1 elif stochrsi_value * self.TULIPY_INDICATOR_MULTIPLICATOR <= self.low_level: self.eval_note = -1 else: self.eval_note = stochrsi_value - 0.5 except tulipy.lib.InvalidOptionError as e: message = "" if self.period <= 1: message = " period should be higher than 1." self.logger.warning(f"Error when computing StochasticRSIVolatilityEvaluator: {e}{message}") self.logger.exception(e, False) self.eval_note = commons_constants.START_PENDING_EVAL_NOTE await self.evaluation_completed(cryptocurrency, symbol, time_frame, eval_time=evaluators_util.get_eval_time(full_candle=candle, time_frame=time_frame)) ================================================ FILE: Evaluator/Util/candles_util/__init__.py ================================================ from .candles_util import CandlesUtil ================================================ FILE: Evaluator/Util/candles_util/candles_util.pxd ================================================ # cython: language_level=3 # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. cimport numpy as np from math cimport mean cpdef object HL2(object high, object low) cpdef object HLC3(object high, object low, object close) cpdef object OHLC4(object open, object high, object low, object close) cpdef tuple HeikinAshi(object open, object high, object low, object close) ================================================ FILE: Evaluator/Util/candles_util/candles_util.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy as np from octobot_commons.data_util import mean class CandlesUtil: @staticmethod def HL2(candles_high, candles_low): """ Return a list of HL2 value (high + low ) / 2 :param high: list of high :param low: list of low :return: list of HL2 """ return np.array(list(map((lambda candles_high, candles_low: mean([candles_high, candles_low])), candles_high, candles_low))) @staticmethod def HLC3(candles_high, candles_low, candles_close): """ Return a list of HLC3 values (high + low + close) / 3 :param high: list of high :param low: list of low :param close: list of close :return: list of HLC3 """ return np.array(list(map((lambda candles_high, candles_low, candles_close: mean([candles_high, candles_low, candles_close])), candles_high, candles_low, candles_close))) @staticmethod def OHLC4(candles_open, candles_high, candles_low, candles_close): """ Return a list of OHLC4 value (open + high + low + close) / 4 :param open: list of open :param high: list of high :param low: list of low :param close: list of close :return: list of OHLC4 """ return np.array(list(map((lambda candles_open, candles_high, candles_low, candles_close: mean([candles_open, candles_high, candles_low, candles_close])), candles_open, candles_high, candles_low, candles_close))) @staticmethod def HeikinAshi(candles_open, candles_high, candles_low, candles_close): """ Return HeikinAshi array of the given candles :param open: list of open :param high: list of high :param low: list of low :param close: list of close :return: HAopen, HAhigh, HAlow, HAclose """ haOpen, haHigh, haLow, haClose = [np.array([]) for i in range(4)] for i, (open_value, high_value, low_value, close_value) \ in enumerate(zip(candles_open, candles_high, candles_low, candles_close)): if i == 0: haOpen = np.append(haOpen, open_value) haHigh = np.append(haHigh, high_value) haLow = np.append(haLow, low_value) haClose = np.append(haClose, close_value) continue haOpen = np.append(haOpen, mean([candles_open[i-1], candles_close[i-1]])) haHigh = np.append(haHigh, high_value) haLow = np.append(haLow, low_value) haClose = np.append(haClose, mean([open_value, high_value, low_value, close_value])) return haOpen, haHigh, haLow, haClose ================================================ FILE: Evaluator/Util/candles_util/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CandlesUtil"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Util/candles_util/tests/test_candles_util.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy as np from tentacles.Evaluator.Util import CandlesUtil def test_HL2(): candles_high = np.array([10, 12, np.nan, 45, 5.67, 6.54, 75, 8.01, 9]) candles_low = np.array([9, 8, 7, 6, 5, 4, 3, 2, 1]) np.testing.assert_array_equal(CandlesUtil.HL2(candles_high, candles_low), np.array([9.5, 10, np.nan, 25.5, 5.335, 5.27, 39.0, 5.005, 5.0], dtype=np.float64)) candles_high = np.array([120, 123, 54, 45, 210.54, 546.21, 981.2, .958, 65.7]) candles_low = np.array([887.592, 896.519, 97.416, 233.987, 846.789, 713.054, 856.985, 421.17, 874.296]) np.testing.assert_array_equal(CandlesUtil.HL2(candles_high, candles_low), np.array([503.796, 509.7595, 75.708, 139.49349999999998, 528.6645, 629.6320000000001, 919.0925, 211.06400000000002, 469.99800000000005], dtype=np.float64)) def test_HLC3(): candles_high = np.array([9, 13, np.nan, 45, 5.67, 6.54, 75, 8.01, 9]) candles_low = np.array([19, 25, 17, 36, 45, 84, 31, 21, 10]) candles_close = np.array([2, 4, 4, 4, 6, 7, 8, 9, 10]) np.testing.assert_array_equal(CandlesUtil.HLC3(candles_high, candles_low, candles_close), np.array([10, 14, np.nan, 28.333333333333332, 18.89, 32.513333333333335, 38, 12.67, 9.666666666666666], dtype=np.float64)) candles_high = np.array([733.985, 86.751, 388.834, 630.849, 231.102, 224.815, 430.74, 776.919, 209.207]) candles_low = np.array([145.747, 829.698, 534.426, 879.53, 187.895, 698.515, 822.942, 532.641, 626.917]) candles_close = np.array([811.199, 278.313, 817.295, 315.199, 974.104, 775.321, 979.139, 790.477, 518.736]) np.testing.assert_array_equal(CandlesUtil.HLC3(candles_high, candles_low, candles_close), np.array([563.6436666666667, 398.25399999999996, 580.185, 608.526, 464.367, 566.217, 744.2736666666666, 700.0123333333332, 451.62000000000006], dtype=np.float64)) def test_OHLC4(): candles_open = np.array([251.613, 259.098, 247.819, 140.73, 237.547, 830.611, 433.168, 404.026, 403.538]) candles_high = np.array([980.99, 403.92, 698.072, 658.647, 245.151, 480.9, 621.35, 429.109, 637.439]) candles_low = np.array([658.777, 101.13, 549.588, 28.624, 132.07, 813.572, 366.478, 619.649, 371.696]) candles_close = np.array([812.829, 880.456, 406.039, 39.224, 917.386, 707.281, 737.851, 330.262, 258.689]) np.testing.assert_array_equal(CandlesUtil.OHLC4(candles_open, candles_high, candles_low, candles_close), np.array([676.05225, 411.151, 475.37949999999995, 216.80625000000003, 383.0385, 708.091, 539.71175, 445.7615, 417.84049999999996], dtype=np.float64)) candles_open = np.array([345.468, 484.778, 332.855, 401.893, 41.936, 333.738, 983.158, 996.979, 807.855]) candles_high = np.array([547.277, 856.206, 439.542, 921.475, 778.994, 156.285, 653.31, 534.865, 427.64]) candles_low = np.array([328.444, 593.535, 4.243, 83.902, 811.859, 396.442, 433.552, 127.624, 314.613]) candles_close = np.array([905.792, 382.98, 135.529, 494.942, 510.52, 399.78, 897.088, 192.068, 771.189]) np.testing.assert_array_equal(CandlesUtil.OHLC4(candles_open, candles_high, candles_low, candles_close), np.array([531.74525, 579.37475, 228.04225, 475.553, 535.82725, 321.56125, 741.777, 462.884, 580.32425], dtype=np.float64)) def test_HeikinAshi(): candles_open = np.array([977.88, 573.634, 816.233, 846.748, 184.114, 35.742, 598.653, 745.916, 854.334]) candles_high = np.array([4.757, 499.759, 602.794, 179.313, 802.019, 384.307, 637.378, 161.048, 366.51]) candles_low = np.array([903.152, 877.832, 966.154, 104.582, 837.638, 568.788, 788.584, 510.926, 608.184]) candles_close = np.array([405.527, 685.962, 495.698, 271.687, 573.667, 891.018, 445.342, 344.928, 894.279]) haOpen, haHigh, haLow, haClose = CandlesUtil.HeikinAshi(candles_open, candles_high, candles_low, candles_close) np.testing.assert_array_equal(haOpen, np.array([977.88, 691.7035, 629.798, 655.9655, 559.2175, 378.89050000000003, 463.38, 521.9975, 545.422], dtype=np.float64)) np.testing.assert_array_equal(haHigh, np.array([4.757, 499.759, 602.794, 179.313, 802.019, 384.307, 637.378, 161.048, 366.51], dtype=np.float64)) np.testing.assert_array_equal(haLow, np.array([903.152, 877.832, 966.154, 104.582, 837.638, 568.788, 788.584, 510.926, 608.184], dtype=np.float64)) np.testing.assert_array_equal(haClose, np.array([405.527, 659.29675, 720.21975, 350.5825, 599.3595, 469.96375, 617.48925, 440.70450000000005, 680.82675], dtype=np.float64)) candles_open = np.array([188.539, 334.682, 495.604, 638.736, 632.213, 705.675, 876.735, 69.951, 909.477]) candles_high = np.array([259.316, 843.705, 170.388, 318.961, 918.236, 585.595, 23.266, 657.422, 270.557]) candles_low = np.array([652.361, 293.607, 295.191, 893.255, 819.447, 647.016, 330.303, 472.415, 617.705]) candles_close = np.array([968.007, 114.792, 680.216, 168.147, 478.577, 437.676, 299.474, 208.601, 333.237]) haOpen, haHigh, haLow, haClose = CandlesUtil.HeikinAshi(candles_open, candles_high, candles_low, candles_close) np.testing.assert_array_equal(haOpen, np.array([188.539, 578.2729999999999, 224.73700000000002, 587.91, 403.4415, 555.395, 571.6754999999999, 588.1045, 139.276], dtype=np.float64)) np.testing.assert_array_equal(haHigh, np.array([259.316, 843.705, 170.388, 318.961, 918.236, 585.595, 23.266, 657.422, 270.557], dtype=np.float64)) np.testing.assert_array_equal(haLow, np.array([652.361, 293.607, 295.191, 893.255, 819.447, 647.016, 330.303, 472.415, 617.705], dtype=np.float64)) np.testing.assert_array_equal(haClose, np.array([968.007, 396.6965, 410.34975, 504.77475, 712.11825, 593.9905, 382.4445, 352.09725000000003, 532.744], dtype=np.float64)) ================================================ FILE: Evaluator/Util/overall_state_analysis/__init__.py ================================================ from .overall_state_analysis import OverallStateAnalyser ================================================ FILE: Evaluator/Util/overall_state_analysis/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["OverallStateAnalyser"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Util/overall_state_analysis/overall_state_analysis.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy import octobot_commons.constants as commons_constants class OverallStateAnalyser: def __init__(self): self.overall_state = commons_constants.START_PENDING_EVAL_NOTE self.evaluation_count = 0 self.evaluations = [] # evaluation: number between -1 and 1 # weight: integer between 0 (not even taken into account) and X def add_evaluation(self, evaluation, weight, refresh_overall_state=True): self.evaluations.append(StateEvaluation(evaluation, weight)) if refresh_overall_state: self._refresh_overall_state() def get_overall_state_after_refresh(self, refresh_overall_state=True): if refresh_overall_state: self._refresh_overall_state() return self.overall_state # computes self.overall_state using self.evaluations values and weights def _refresh_overall_state(self): if self.evaluations: self.overall_state = numpy.mean( [evaluation.value for evaluation in self.evaluations for _ in range(evaluation.weight)] ) class StateEvaluation: def __init__(self, value, weight): self.value = value self.weight = weight ================================================ FILE: Evaluator/Util/pattern_analysis/__init__.py ================================================ from .pattern_analysis import PatternAnalyser ================================================ FILE: Evaluator/Util/pattern_analysis/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["PatternAnalyser"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Util/pattern_analysis/pattern_analysis.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy as np import math class PatternAnalyser: UNKNOWN_PATTERN = "?" # returns the starting and ending index of the pattern if it's found # supported patterns: # W, M, N and V (ex: for macd) # return boolean (pattern found or not), start index and end index @staticmethod def find_pattern(data, zero_crossing_indexes, data_frame_max_index): if len(zero_crossing_indexes) > 1: last_move_data = data[zero_crossing_indexes[-1]:] # if last_move_data is shaped in W shape = PatternAnalyser.get_pattern(last_move_data) if shape == "N" or shape == "V": # check presence of W or M with insignificant move in the other direction backwards_index = 2 while backwards_index < len(zero_crossing_indexes) and \ zero_crossing_indexes[-1*backwards_index] - zero_crossing_indexes[-1*backwards_index-1] < 4: backwards_index += 1 extended_last_move_data = data[zero_crossing_indexes[-1 * backwards_index]:] extended_shape = PatternAnalyser.get_pattern(extended_last_move_data) if extended_shape == "W" or extended_shape == "M": # check that values are on the same side (< or >0) first_part = data[zero_crossing_indexes[-1 * backwards_index]: zero_crossing_indexes[-1*backwards_index+1]] second_part = data[zero_crossing_indexes[-1]:] if np.mean(first_part)*np.mean(second_part) > 0: return extended_shape, zero_crossing_indexes[-1*backwards_index], zero_crossing_indexes[-1] return shape, zero_crossing_indexes[-1], data_frame_max_index else: # if very few data: proceed with basic analysis # if last_move_data is shaped in W start_pattern_index = 0 if not zero_crossing_indexes else zero_crossing_indexes[0] shape = PatternAnalyser.get_pattern(data[start_pattern_index:]) return shape, start_pattern_index, data_frame_max_index @staticmethod def get_pattern(data): if len(data) > 0: mean_value = np.mean(data) * 0.7 else: mean_value = math.nan if math.isnan(mean_value): return PatternAnalyser.UNKNOWN_PATTERN indexes_under_mean_value = np.where(data > mean_value)[0] \ if mean_value < 0 \ else np.where(data < mean_value)[0] nb_gaps = 0 for i in range(len(indexes_under_mean_value)-1): if indexes_under_mean_value[i+1]-indexes_under_mean_value[i] > 3: nb_gaps += 1 if nb_gaps > 1: return "W" if mean_value < 0 else "M" else: return "V" if mean_value < 0 else "N" # returns a value 0 < value < 1: the higher the stronger is the pattern @staticmethod def get_pattern_strength(pattern): if pattern == "W" or pattern == "M": return 1 elif pattern == "N" or pattern == "V": return 0.75 return 0 ================================================ FILE: Evaluator/Util/statistics_analysis/__init__.py ================================================ from .statistics_analysis import StatisticAnalysis ================================================ FILE: Evaluator/Util/statistics_analysis/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["StatisticAnalysis"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Util/statistics_analysis/statistics_analysis.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tulipy import numpy import octobot_commons.constants as commons_constants class StatisticAnalysis: # Return linear proximity to the lower or the upper band relatively to the middle band. # Linearly compute proximity between middle and delta before linear: @staticmethod def analyse_recent_trend_changes(data, delta_function): # compute bollinger bands lower_band, middle_band, upper_band = tulipy.bbands(data, 20, 2) # if close to lower band => low value => bad, # therefore if close to middle, value is keeping up => good # finally if up the middle one or even close to the upper band => very good current_value = data[-1] current_up = upper_band[-1] current_middle = middle_band[-1] current_low = lower_band[-1] delta_up = current_up - current_middle delta_low = current_middle - current_low # its exactly on all bands if current_up == current_low: return commons_constants.START_PENDING_EVAL_NOTE # exactly on the middle elif current_value == current_middle: return 0 # up the upper band elif current_value > current_up: return -1 # down the lower band elif current_value < current_low: return 1 # delta given: use parabolic factor after delta, linear before delta = delta_function(numpy.mean([delta_up, delta_low])) micro_change = ((current_value / current_middle) - 1) / 2 # approximately on middle band if current_middle + delta >= current_value >= current_middle - delta: return micro_change # up the middle area elif current_middle + delta < current_value: return -1 * max(micro_change, (current_value - current_middle) / delta_up) # down the middle area elif current_middle - delta > current_value: return max(micro_change, (current_middle - current_value) / delta_low) # should not happen return 0 ================================================ FILE: Evaluator/Util/text_analysis/__init__.py ================================================ from .text_analysis import TextAnalysis ================================================ FILE: Evaluator/Util/text_analysis/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TextAnalysis"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Util/text_analysis/text_analysis.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as commons_constants try: import vaderSentiment.vaderSentiment as vaderSentiment except ImportError: if commons_constants.USE_MINIMAL_LIBS: # mock vaderSentiment imports class VaderSentimentImportMock: class SentimentIntensityAnalyzer: def __init__(self, *args): raise ImportError("vaderSentiment not installed") vaderSentiment = VaderSentimentImportMock() class TextAnalysis: IMAGE_ENDINGS = ["png", "jpg", "jpeg", "gif", "jfif", "tiff", "bmp", "ppm", "pgm", "pbm", "pnm", "webp", "hdr", "heif", "bat", "bpg", "svg", "cgm"] def __init__(self): super().__init__() self.analyzer = vaderSentiment.SentimentIntensityAnalyzer() # self.test() def analyse(self, text): # The compound score is computed by summing the valence scores of each word in the lexicon, adjusted according # to the rules, and then normalized to be between -1 (most extreme negative) and +1 (most extreme positive). # https://github.com/cjhutto/vaderSentiment return self.analyzer.polarity_scores(text)["compound"] # return a list of high influential value websites @staticmethod def get_high_value_websites(): return [ "https://www.youtube.com" ] @staticmethod def is_analysable_url(url): url_ending = str(url).split(".")[-1] return url_ending.lower() not in TextAnalysis.IMAGE_ENDINGS # official account tweets that can be used for testing purposes def test(self): texts = [ "Have you read about VeChain and INPI ASIA's integration to bring nanotechnology for digital identity to " "the VeChainThor blockchain? NDCodes resist high temperature, last over 100 years, are incredibly durable " "and invisible to the naked eye", "A scientific hypothesis about how cats, infected with toxoplasmosis, are making humans buy Bitcoin was " "presented at last night's BAHFest at MIT.", "Net Neutrality Ends! Substratum Update 4.23.18", "One more test from @SubstratumNet for today. :)", "Goldman Sachs hires crypto trader as head of digital assets markets", "Big news coming! Scheduled to be 27th/28th April... Have a guess...", "This week's Theta Surge on http://SLIVER.tv isn't just for virtual items... five PlayStation 4s will " "be given out to viewers that use Theta Tokens to reward the featured #Fortnite streamer! Tune in this " "Friday at 1pm PST to win!", "The European Parliament has voted for regulations to prevent the use of cryptocurrencies in money " "laundering and terrorism financing. As long as they have good intention i don' t care.. but how " "much can we trust them??!?!" "By partnering with INPI ASIA, the VeChainThor Platform incorporates nanotechnology with digital " "identification to provide solutions to some of the worlds most complex IoT problems.", "Thanks to the China Academy of Information and Communication Technology, IPRdaily and Nashwork for " "organizing the event.", "Delivered a two hour open course last week in Beijing. You can tell the awareness of blockchain is " "drastically increasing by the questions asked by the audience. But people need hand holding and " "business friendly features to adopt the tech.", "Introducing the first Oracle Enabler tool of the VeChainThor Platform: Multi-Party Payment Protocol " "(MPP).", "An open letter from Sunny Lu (CEO) on VeChainThor Platform.", "VeChain has finished the production of digital intellectual property services with partner iTaotaoke. " "This solution provides a competitive advantage for an industry in need of trust-free reporting and " "content protections.#GoVeChain", "Special thanks to @GaboritMickael to have invited @vechainofficial to present our solution and make " "a little demo to @AccentureFrance", "VeChain will pitch their solutions potentially landing a co-development product with LVMH. In " "attendance will be CEOs Bill McDermott (SAP), Chuck Robbins (CISCO), Ginni Rometty (IBM), and Stephane " "Richard (Orange) as speakers -", "As the only blockchain company selected, VeChain is among 30 of 800+ hand-picked startups to compete " "for the second edition of the LVMH Innovation Award. As a result, VeChain has been invited to join the " "Luxury Lab LVMH at Viva Technology in Paris from May 24-26, 2018.", "VeChain to further its partnership with RFID leader Xiamen Innov and newly announced top enterprise " "solution provider CoreLink by deploying a VeChainThor enterprise level decentralized application - " "AssetLink.", "Today, a group of senior leaders from TCL's Eagle Talent program visited the VeChain SH office. " "@VeChain_GU demonstrated our advanced enterprise solutions and it's relation to TCL's market. As a " "result, we're exploring new developments within TCL related to blockchain technology.", "We are glad to be recognized as Top 10 blockchain technology solution providers in 2018. outprovides a " "platform for CIOs and decision makers to share their experiences, wisdom and advice. Read the full " "version article via", "Talked about TOTO at the blockchain seminar in R University of Science and Technology business school " "last Saturday. It covered 3000 MBA students across business schools in China." ] for text in texts: print(str(self.analyse(text)) + " => "+str(text.encode("utf-8", "ignore"))) ================================================ FILE: Evaluator/Util/trend_analysis/__init__.py ================================================ from .trend_analysis import TrendAnalysis ================================================ FILE: Evaluator/Util/trend_analysis/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TrendAnalysis"], "tentacles-requirements": [] } ================================================ FILE: Evaluator/Util/trend_analysis/trend_analysis.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy as np class TrendAnalysis: # trend < 0 --> Down trend # trend > 0 --> Up trend @staticmethod def get_trend(data, averages_to_use): trend = 0 inc = round(1 / len(averages_to_use), 2) averages = [] # Get averages for average_to_use in averages_to_use: data_to_mean = data[-average_to_use:] if len(data_to_mean): averages.append(np.mean(data_to_mean)) else: averages.append(0) for a in range(0, len(averages) - 1): if averages[a] - averages[a + 1] > 0: trend -= inc else: trend += inc return trend @staticmethod def peak_has_been_reached_already(data, neutral_val=0): if len(data) > 1: min_val = min(data) max_val = max(data) current_val = data[-1] / 0.8 if current_val > neutral_val: return current_val < max_val else: return current_val > min_val else: return False @staticmethod def min_has_just_been_reached(data, acceptance_window=0.8, delay=1): if len(data) > 1: min_val = min(data) current_val = data[-1] / acceptance_window accepted_delayed_min = data[-(delay+1):] return bool(min_val in accepted_delayed_min and current_val > min_val) else: return False @staticmethod # TODO def detect_divergence(data_frame, indicator_data_frame): pass # candle_data = data_frame.tail(DIVERGENCE_USED_VALUE) # indicator_data = indicator_data_frame.tail(DIVERGENCE_USED_VALUE) # # total_delta = [] # # for i in range(0, DIVERGENCE_USED_VALUE - 1): # candle_delta = candle_data.values[i] - candle_data.values[i + 1] # indicator_delta = indicator_data.values[i] - indicator_data.values[i + 1] # total_delta.append(candle_delta - indicator_delta) @staticmethod def get_estimation_of_move_state_relatively_to_previous_moves_length(mean_crossing_indexes, current_trend, pattern_move_size=1, double_size_patterns_count=0): if mean_crossing_indexes: # compute average move size time_averages = [(lambda a: mean_crossing_indexes[a+1]-mean_crossing_indexes[a])(a) for a in range(len(mean_crossing_indexes)-1)] # add 1st length if 0 != mean_crossing_indexes[0]: time_averages.append(mean_crossing_indexes[0]) # take double_size_patterns_count into account time_averages += [0]*double_size_patterns_count time_average = np.mean(time_averages)*pattern_move_size if time_averages else 0 current_move_length = len(current_trend) - mean_crossing_indexes[-1] # higher than time_average => high chances to be at half of the move already if current_move_length > time_average/2: return 1 else: return current_move_length / (time_average/2) else: return 0 @staticmethod def get_threshold_change_indexes(data, threshold): # sub threshold values sub_threshold_indexes = np.where(data <= threshold)[0] # remove consecutive sub-threshold values because they are not crosses threshold_crossing_indexes = [] current_move_size = 1 for i, index in enumerate(sub_threshold_indexes): if not len(threshold_crossing_indexes): threshold_crossing_indexes.append(index) else: if threshold_crossing_indexes[-1] == index - current_move_size: current_move_size += 1 else: if sub_threshold_indexes[i-1] not in threshold_crossing_indexes: threshold_crossing_indexes.append(sub_threshold_indexes[i-1]) if index not in threshold_crossing_indexes: threshold_crossing_indexes.append(index) current_move_size = 1 # add last index if data_frame ends above threshold and last threshold_crossing_indexes inferior # to data_frame size if len(sub_threshold_indexes) > 0 \ and sub_threshold_indexes[-1] < len(data) \ and data[-1] > threshold \ and sub_threshold_indexes[-1]+1 not in threshold_crossing_indexes: threshold_crossing_indexes.append(sub_threshold_indexes[-1]+1) return threshold_crossing_indexes @staticmethod def have_just_crossed_over(list_1, list_2): # returns True if the last value of list_1 is higher than the last value of list_2 but the immediately # preceding list_1 value is lower than the one from list_2 try: return list_1[-1] > list_2[-1] and list_1[-2] < list_2[-2] except KeyError: return False ================================================ FILE: LICENSE ================================================ GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ================================================ FILE: Meta/DSL_operators/exchange_operators/__init__.py ================================================ # pylint: disable=R0801 # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators from tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators import ( OHLCVOperator, ExchangeDataDependency, create_ohlcv_operators, ) import tentacles.Meta.DSL_operators.exchange_operators.exchange_private_data_operators from tentacles.Meta.DSL_operators.exchange_operators.exchange_private_data_operators import ( PortfolioOperator, create_portfolio_operators, ) __all__ = [ "OHLCVOperator", "ExchangeDataDependency", "create_ohlcv_operators", "PortfolioOperator", "create_portfolio_operators", ] ================================================ FILE: Meta/DSL_operators/exchange_operators/exchange_operator.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges import octobot_commons.dsl_interpreter.operators.call_operator as dsl_interpreter_call_operator import octobot_trading.modes.script_keywords as script_keywords EXCHANGE_LIBRARY = "exchange" UNINITIALIZED_VALUE = object() class ExchangeOperator(dsl_interpreter_call_operator.CallOperator): @staticmethod def get_library() -> str: """ Get the library of the operator. """ return EXCHANGE_LIBRARY async def get_context( self, exchange_manager: octobot_trading.exchanges.ExchangeManager ) -> script_keywords.Context: # todo later: handle exchange manager without initialized trading modes return script_keywords.get_base_context(next(iter(exchange_manager.trading_modes))) ================================================ FILE: Meta/DSL_operators/exchange_operators/exchange_private_data_operators/__init__.py ================================================ # pylint: disable=R0801 # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.DSL_operators.exchange_operators.exchange_private_data_operators.portfolio_operators from tentacles.Meta.DSL_operators.exchange_operators.exchange_private_data_operators.portfolio_operators import ( PortfolioOperator, create_portfolio_operators, ) __all__ = [ "PortfolioOperator", "create_portfolio_operators", ] ================================================ FILE: Meta/DSL_operators/exchange_operators/exchange_private_data_operators/portfolio_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_commons.constants import octobot_commons.errors import octobot_commons.dsl_interpreter as dsl_interpreter import octobot_trading.personal_data import octobot_trading.exchanges import octobot_trading.api import tentacles.Meta.DSL_operators.exchange_operators.exchange_operator as exchange_operator class PortfolioOperator(exchange_operator.ExchangeOperator): def __init__(self, *parameters: dsl_interpreter.OperatorParameterType, **kwargs: typing.Any): super().__init__(*parameters, **kwargs) self.value: dsl_interpreter_operator.ComputedOperatorParameterType = exchange_operator.UNINITIALIZED_VALUE # type: ignore @staticmethod def get_library() -> str: # this is a contextual operator, so it should not be included by default in the get_all_operators function return values return octobot_commons.constants.CONTEXTUAL_OPERATORS_LIBRARY @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="asset", description="the asset to get the value for", required=False, type=str), ] def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: if self.value is exchange_operator.UNINITIALIZED_VALUE: raise octobot_commons.errors.DSLInterpreterError("{self.__class__.__name__} has not been initialized") return self.value def create_portfolio_operators( exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager], ) -> typing.List[type[PortfolioOperator]]: def _get_asset_holdings(asset: str) -> octobot_trading.personal_data.Asset: return octobot_trading.api.get_portfolio_currency(exchange_manager, asset) class _TotalOperator(PortfolioOperator): DESCRIPTION = "Returns the total holdings of the asset in the portfolio" EXAMPLE = "total('BTC')" @staticmethod def get_name() -> str: return "total" async def pre_compute(self) -> None: await super().pre_compute() asset = self.get_computed_parameters()[0] self.value = float(_get_asset_holdings(asset).total) class _AvailableOperator(PortfolioOperator): DESCRIPTION = "Returns the available holdings of the asset in the portfolio" EXAMPLE = "available('BTC')" @staticmethod def get_name() -> str: return "available" async def pre_compute(self) -> None: await super().pre_compute() asset = self.get_computed_parameters()[0] self.value = float(_get_asset_holdings(asset).available) return [_TotalOperator, _AvailableOperator] ================================================ FILE: Meta/DSL_operators/exchange_operators/exchange_public_data_operators/__init__.py ================================================ # pylint: disable=R0801 # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.ohlcv_operators from tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.ohlcv_operators import ( OHLCVOperator, ExchangeDataDependency, create_ohlcv_operators, ) __all__ = [ "OHLCVOperator", "ExchangeDataDependency", "create_ohlcv_operators", ] ================================================ FILE: Meta/DSL_operators/exchange_operators/exchange_public_data_operators/ohlcv_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import dataclasses import numpy as np import octobot_commons.constants import octobot_commons.errors import octobot_commons.logging import octobot_commons.enums as commons_enums import octobot_commons.dsl_interpreter as dsl_interpreter import octobot_trading.exchanges import octobot_trading.exchange_data import octobot_trading.api import octobot_trading.constants import tentacles.Meta.DSL_operators.exchange_operators.exchange_operator as exchange_operator @dataclasses.dataclass class ExchangeDataDependency(dsl_interpreter.InterpreterDependency): exchange_manager_id: str symbol: typing.Optional[str] time_frame: typing.Optional[str] data_source: str = octobot_trading.constants.OHLCV_CHANNEL def __hash__(self) -> int: return hash((self.exchange_manager_id, self.symbol, self.time_frame, self.data_source)) class OHLCVOperator(exchange_operator.ExchangeOperator): def __init__(self, *parameters: dsl_interpreter.OperatorParameterType, **kwargs: typing.Any): super().__init__(*parameters, **kwargs) self.value: dsl_interpreter_operator.ComputedOperatorParameterType = exchange_operator.UNINITIALIZED_VALUE # type: ignore @staticmethod def get_library() -> str: # this is a contextual operator, so it should not be included by default in the get_all_operators function return values return octobot_commons.constants.CONTEXTUAL_OPERATORS_LIBRARY @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="symbol", description="the symbol to get the OHLCV data for", required=False, type=str), dsl_interpreter.OperatorParameter(name="time_frame", description="the time frame to get the OHLCV data for", required=False, type=str), ] def get_symbol_and_time_frame(self) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: if parameters := self.get_computed_parameters(): symbol = parameters[0] if len(parameters) > 0 else None time_frame = parameters[1] if len(parameters) > 1 else None return ( str(symbol) if symbol is not None else None, str(time_frame) if time_frame is not None else None ) return None, None def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: if self.value is exchange_operator.UNINITIALIZED_VALUE: raise octobot_commons.errors.DSLInterpreterError("{self.__class__.__name__} has not been initialized") return self.value def create_ohlcv_operators( exchange_manager: typing.Optional[octobot_trading.exchanges.ExchangeManager], symbol: typing.Optional[str], time_frame: typing.Optional[str], candle_manager_by_time_frame_by_symbol: typing.Optional[ typing.Dict[str, typing.Dict[str, octobot_trading.exchange_data.CandlesManager]] ] = None ) -> typing.List[type[OHLCVOperator]]: if exchange_manager is None and candle_manager_by_time_frame_by_symbol is None: raise octobot_commons.errors.InvalidParametersError("exchange_manager or candle_manager_by_time_frame_by_symbol must be provided") def _get_candles_values_with_latest_kline_if_available( input_symbol: typing.Optional[str], input_time_frame: typing.Optional[str], value_type: commons_enums.PriceIndexes, limit: int = -1 ) -> np.ndarray: _symbol = input_symbol or symbol _time_frame = input_time_frame or time_frame if exchange_manager is None: if candle_manager_by_time_frame_by_symbol is not None: candles_manager = candle_manager_by_time_frame_by_symbol[_time_frame][_symbol] symbol_data = None else: symbol_data = octobot_trading.api.get_symbol_data( exchange_manager, _symbol, allow_creation=False ) candles_manager = octobot_trading.api.get_symbol_candles_manager( symbol_data, _time_frame ) candles_values = _get_candles_values(candles_manager, value_type, limit) if symbol_data is not None and (kline := _get_kline(symbol_data, _time_frame)): kline_time = kline[commons_enums.PriceIndexes.IND_PRICE_TIME.value] last_candle_time = candles_manager.time_candles[candles_manager.time_candles_index - 1] if kline_time == last_candle_time: # kline is an update of the last candle return _adapt_last_candle_value(candles_manager, value_type, candles_values, kline) else: tf_seconds = commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(_time_frame)] * octobot_commons.constants.MINUTE_TO_SECONDS if kline_time == last_candle_time + tf_seconds: # kline is a new candle kline_value = kline[value_type.value] return np.append(candles_values[1:], kline_value) else: octobot_commons.logging.get_logger(OHLCVOperator.__name__).error( f"{exchange_manager.exchange_name + '' if exchange_manager is not None else ''}{_symbol} {_time_frame} " f"kline time ({kline_time}) is not equal to last candle time not the last time + {_time_frame} " f"({last_candle_time} + {tf_seconds}) seconds. Kline has been ignored." ) return candles_values def _get_dependencies() -> typing.List[ExchangeDataDependency]: return [ ExchangeDataDependency( exchange_manager_id=octobot_trading.api.get_exchange_manager_id(exchange_manager), symbol=symbol, time_frame=time_frame ) ] class _LocalOHLCVOperator(OHLCVOperator): PRICE_INDEX: commons_enums.PriceIndexes = None # type: ignore def get_dependencies(self) -> typing.List[dsl_interpreter.InterpreterDependency]: return super().get_dependencies() + _get_dependencies() async def pre_compute(self) -> None: await super().pre_compute() self.value = _get_candles_values_with_latest_kline_if_available(*self.get_symbol_and_time_frame(), self.PRICE_INDEX, -1) class _OpenPriceOperator(_LocalOHLCVOperator): DESCRIPTION = "Returns the candle's open price as array of floats" EXAMPLE = "open('BTC/USDT', '1h')" PRICE_INDEX = commons_enums.PriceIndexes.IND_PRICE_OPEN @staticmethod def get_name() -> str: return "open" class _HighPriceOperator(_LocalOHLCVOperator): DESCRIPTION = "Returns the candle's high price as array of floats" EXAMPLE = "high('BTC/USDT', '1h')" PRICE_INDEX = commons_enums.PriceIndexes.IND_PRICE_HIGH @staticmethod def get_name() -> str: return "high" class _LowPriceOperator(_LocalOHLCVOperator): DESCRIPTION = "Returns the candle's low price as array of floats" EXAMPLE = "low('BTC/USDT', '1h')" PRICE_INDEX = commons_enums.PriceIndexes.IND_PRICE_LOW @staticmethod def get_name() -> str: return "low" class _ClosePriceOperator(_LocalOHLCVOperator): DESCRIPTION = "Returns the candle's close price as array of floats" EXAMPLE = "close('BTC/USDT', '1h')" PRICE_INDEX = commons_enums.PriceIndexes.IND_PRICE_CLOSE @staticmethod def get_name() -> str: return "close" class _VolumePriceOperator(_LocalOHLCVOperator): DESCRIPTION = "Returns the candle's volume as array of floats" EXAMPLE = "volume('BTC/USDT', '1h')" PRICE_INDEX = commons_enums.PriceIndexes.IND_PRICE_VOL @staticmethod def get_name() -> str: return "volume" class _TimePriceOperator(_LocalOHLCVOperator): DESCRIPTION = "Returns the candle's time as array of floats" EXAMPLE = "time('BTC/USDT', '1h')" PRICE_INDEX = commons_enums.PriceIndexes.IND_PRICE_TIME @staticmethod def get_name() -> str: return "time" return [_OpenPriceOperator, _HighPriceOperator, _LowPriceOperator, _ClosePriceOperator, _VolumePriceOperator, _TimePriceOperator] def _get_kline( symbol_data: octobot_trading.exchange_data.ExchangeSymbolData, _time_frame: str ) -> typing.Optional[list]: try: return octobot_trading.api.get_symbol_klines(symbol_data, _time_frame) except KeyError: return None def _get_candles_values( candles_manager: octobot_trading.exchange_data.CandlesManager, candle_value: commons_enums.PriceIndexes, limit: int = -1 ) -> np.ndarray: match candle_value: case commons_enums.PriceIndexes.IND_PRICE_CLOSE: return candles_manager.get_symbol_close_candles(limit) case commons_enums.PriceIndexes.IND_PRICE_OPEN: return candles_manager.get_symbol_open_candles(limit) case commons_enums.PriceIndexes.IND_PRICE_HIGH: return candles_manager.get_symbol_high_candles(limit) case commons_enums.PriceIndexes.IND_PRICE_LOW: return candles_manager.get_symbol_low_candles(limit) case commons_enums.PriceIndexes.IND_PRICE_VOL: return candles_manager.get_symbol_volume_candles(limit) case commons_enums.PriceIndexes.IND_PRICE_TIME: return candles_manager.get_symbol_time_candles(limit) case _: raise octobot_commons.errors.InvalidParametersError(f"Invalid candle value: {candle_value}") def _adapt_last_candle_value( candles_manager: octobot_trading.exchange_data.CandlesManager, candle_value: commons_enums.PriceIndexes, candles_values: np.ndarray, kline: list ) -> np.ndarray: match candle_value: case commons_enums.PriceIndexes.IND_PRICE_CLOSE: candles_values[candles_manager.close_candles_index - 1] = kline[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value] case commons_enums.PriceIndexes.IND_PRICE_OPEN: candles_values[candles_manager.open_candles_index - 1] = kline[commons_enums.PriceIndexes.IND_PRICE_OPEN.value] case commons_enums.PriceIndexes.IND_PRICE_HIGH: candles_values[candles_manager.high_candles_index - 1] = kline[commons_enums.PriceIndexes.IND_PRICE_HIGH.value] case commons_enums.PriceIndexes.IND_PRICE_LOW: candles_values[candles_manager.low_candles_index - 1] = kline[commons_enums.PriceIndexes.IND_PRICE_LOW.value] case commons_enums.PriceIndexes.IND_PRICE_VOL: candles_values[candles_manager.volume_candles_index - 1] = kline[commons_enums.PriceIndexes.IND_PRICE_VOL.value] case commons_enums.PriceIndexes.IND_PRICE_TIME: # nothing to do for time (this value is constant) pass case _: raise octobot_commons.errors.InvalidParametersError(f"Invalid candle value: {candle_value}") return candles_values ================================================ FILE: Meta/DSL_operators/exchange_operators/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": [], "tentacles-requirements": [] } ================================================ FILE: Meta/DSL_operators/exchange_operators/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import mock import pytest import typing import numpy as np import octobot_commons.enums import octobot_commons.errors import octobot_commons.constants import octobot_commons.dsl_interpreter as dsl_interpreter import tentacles.Meta.DSL_operators.exchange_operators as exchange_operators SYMBOL = "BTC/USDT" SYMBOL2 = "ETH/USDT" TIME_FRAME = "1h" TIME_FRAME2 = "4h" KLINE_SIGNATURE = 0.00666 @pytest.fixture def historical_prices(): return np.array([ 81.59, 81.06, 82.87, 83, 83.61, 83.15, 82.84, 83.99, 84.55, 84.36, 85.53, 86.54, 86.89, 87.77, 87.29, 87.18, 87.01, 89.02, 89.68, 90.36, 92.83, 93.37, 93.02, 93.45, 94.13, 93.12, 93.18, 92.08, 92.82, 92.92, 92.25, 92.22 ]) @pytest.fixture def historical_times(historical_prices): return np.array([ i + 10 for i in range(len(historical_prices)) ], dtype=np.float64) @pytest.fixture def historical_volume(historical_prices): base_volume_pattern = [ # will create an int np.array, which will updated to float64 to comply with tulipy requirements 903, 1000, 2342, 992, 900, 1231, 1211, 1113 ] return np.array(base_volume_pattern*(len(historical_prices) // len(base_volume_pattern) + 1), dtype=np.float64)[:len(historical_prices)] def _get_candle_managers(historical_prices, historical_volume, historical_times): btc_1h_candles_manager = mock.Mock( get_symbol_open_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy()), get_symbol_high_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy()), get_symbol_low_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy()), get_symbol_close_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy()), get_symbol_volume_candles=mock.Mock(side_effect=lambda _ : historical_volume.copy()), get_symbol_time_candles=mock.Mock(side_effect=lambda _ : historical_times.copy()), time_candles_index=len(historical_times), open_candles_index=len(historical_prices), high_candles_index=len(historical_prices), low_candles_index=len(historical_prices), close_candles_index=len(historical_prices), volume_candles_index=len(historical_volume), time_candles=historical_times, ) eth_1h_candles_manager = mock.Mock( get_symbol_open_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() / 2), get_symbol_high_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() / 2), get_symbol_low_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() / 2), get_symbol_close_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() / 2), get_symbol_volume_candles=mock.Mock(side_effect=lambda _ : historical_volume.copy() / 2), get_symbol_time_candles=mock.Mock(side_effect=lambda _ : historical_times.copy() / 2), time_candles_index=len(historical_times), open_candles_index=len(historical_prices), high_candles_index=len(historical_prices), low_candles_index=len(historical_prices), close_candles_index=len(historical_prices), volume_candles_index=len(historical_volume), time_candles=historical_times / 2, ) btc_4h_candles_manager = mock.Mock( get_symbol_open_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() * 2), get_symbol_high_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() * 2), get_symbol_low_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() * 2), get_symbol_close_candles=mock.Mock(side_effect=lambda _ : historical_prices.copy() * 2), get_symbol_volume_candles=mock.Mock(side_effect=lambda _ : historical_volume.copy() * 2), get_symbol_time_candles=mock.Mock(side_effect=lambda _ : historical_times.copy() * 2), time_candles_index=len(historical_times), open_candles_index=len(historical_prices), high_candles_index=len(historical_prices), low_candles_index=len(historical_prices), close_candles_index=len(historical_prices), volume_candles_index=len(historical_volume), time_candles=historical_times * 2, ) return ( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, ) def _get_kline(candles_manager: mock.Mock, signature: float, kline_time_delta: typing.Optional[float]) -> list: kline = [0] * len(octobot_commons.enums.PriceIndexes) kline[octobot_commons.enums.PriceIndexes.IND_PRICE_TIME.value] = ( candles_manager.get_symbol_time_candles(-1)[-1] + kline_time_delta if kline_time_delta is not None else candles_manager.get_symbol_time_candles(-1)[-1] ) kline[octobot_commons.enums.PriceIndexes.IND_PRICE_OPEN.value] = candles_manager.get_symbol_open_candles(-1)[-1] + signature kline[octobot_commons.enums.PriceIndexes.IND_PRICE_HIGH.value] = candles_manager.get_symbol_high_candles(-1)[-1] + signature kline[octobot_commons.enums.PriceIndexes.IND_PRICE_LOW.value] = candles_manager.get_symbol_low_candles(-1)[-1] + signature kline[octobot_commons.enums.PriceIndexes.IND_PRICE_CLOSE.value] = candles_manager.get_symbol_close_candles(-1)[-1] + signature kline[octobot_commons.enums.PriceIndexes.IND_PRICE_VOL.value] = candles_manager.get_symbol_volume_candles(-1)[-1] + signature return kline def _get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, kline_type: str ): def _get_symbol_data(symbol: str, **kwargs): symbol_candles = {} one_h_candles_manager = btc_1h_candles_manager if symbol == SYMBOL else eth_1h_candles_manager if symbol == SYMBOL2 else None four_h_candles_manager = btc_4h_candles_manager if symbol == SYMBOL else None # no 4h eth candles if one_h_candles_manager is None and four_h_candles_manager is None: raise octobot_commons.errors.InvalidParametersError(f"Symbol {symbol} not found") symbol_candles[octobot_commons.enums.TimeFrames(TIME_FRAME)] = one_h_candles_manager if four_h_candles_manager: symbol_candles[octobot_commons.enums.TimeFrames(TIME_FRAME2)] = four_h_candles_manager if kline_type == "no_kline": symbol_klines = {} elif kline_type == "same_time_kline": symbol_klines = { octobot_commons.enums.TimeFrames(TIME_FRAME): mock.Mock(kline=_get_kline(one_h_candles_manager, KLINE_SIGNATURE, None)), } if four_h_candles_manager: symbol_klines[octobot_commons.enums.TimeFrames(TIME_FRAME2)] = mock.Mock(kline=_get_kline(four_h_candles_manager, KLINE_SIGNATURE, None)) elif kline_type == "new_time_kline": symbol_klines = { octobot_commons.enums.TimeFrames(TIME_FRAME): mock.Mock(kline=_get_kline( one_h_candles_manager, KLINE_SIGNATURE, octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames(TIME_FRAME)] * octobot_commons.constants.MINUTE_TO_SECONDS )), } if four_h_candles_manager: symbol_klines[octobot_commons.enums.TimeFrames(TIME_FRAME2)] = mock.Mock(kline=_get_kline( four_h_candles_manager, KLINE_SIGNATURE, octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames(TIME_FRAME2)] * octobot_commons.constants.MINUTE_TO_SECONDS )) else: raise NotImplementedError(f"Kline type {kline_type} not implemented") return mock.Mock( symbol_candles=symbol_candles, symbol_klines=symbol_klines ) return _get_symbol_data @pytest.fixture def exchange_manager_with_candles(historical_prices, historical_volume, historical_times): btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager = _get_candle_managers( historical_prices, historical_volume, historical_times ) return mock.Mock( id="exchange_manager_id", exchange_name="binance", exchange_symbols_data=mock.Mock( get_exchange_symbol_data=_get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, "no_kline" ) ) ) @pytest.fixture def exchange_manager_with_candles_and_klines(historical_prices, historical_volume, historical_times): btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager = _get_candle_managers( historical_prices, historical_volume, historical_times ) return mock.Mock( id="exchange_manager_id", exchange_name="binance", exchange_symbols_data=mock.Mock( get_exchange_symbol_data=_get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, "same_time_kline" ) ) ) @pytest.fixture def exchange_manager_with_candles_and_new_candle_klines(historical_prices, historical_volume, historical_times): btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager = _get_candle_managers( historical_prices, historical_volume, historical_times ) return mock.Mock( id="exchange_manager_id", exchange_name="binance", exchange_symbols_data=mock.Mock( get_exchange_symbol_data=_get_symbol_data_factory( btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager, "new_time_kline" ) ) ) @pytest.fixture def candle_manager_by_time_frame_by_symbol(historical_prices, historical_volume, historical_times): btc_1h_candles_manager, eth_1h_candles_manager, btc_4h_candles_manager = _get_candle_managers( historical_prices, historical_volume, historical_times ) return { TIME_FRAME: { SYMBOL: btc_1h_candles_manager, SYMBOL2: eth_1h_candles_manager, }, TIME_FRAME2: { SYMBOL: btc_4h_candles_manager, }, } @pytest.fixture def interpreter(exchange_manager_with_candles): return dsl_interpreter.Interpreter( dsl_interpreter.get_all_operators() + exchange_operators.create_ohlcv_operators(exchange_manager_with_candles, SYMBOL, TIME_FRAME) ) @pytest.fixture def interpreter_with_exchange_manager_and_klines(exchange_manager_with_candles_and_klines): return dsl_interpreter.Interpreter( dsl_interpreter.get_all_operators() + exchange_operators.create_ohlcv_operators(exchange_manager_with_candles_and_klines, SYMBOL, TIME_FRAME) ) @pytest.fixture def interpreter_with_exchange_manager_and_new_candle_klines(exchange_manager_with_candles_and_new_candle_klines): return dsl_interpreter.Interpreter( dsl_interpreter.get_all_operators() + exchange_operators.create_ohlcv_operators(exchange_manager_with_candles_and_new_candle_klines, SYMBOL, TIME_FRAME) ) @pytest.fixture def interpreter_with_candle_manager_by_time_frame_by_symbol(candle_manager_by_time_frame_by_symbol): return dsl_interpreter.Interpreter( dsl_interpreter.get_all_operators() + exchange_operators.create_ohlcv_operators(None, SYMBOL, TIME_FRAME, candle_manager_by_time_frame_by_symbol) ) ================================================ FILE: Meta/DSL_operators/exchange_operators/tests/exchange_public_data_operators/test_ohlcv_operators.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import numpy as np import octobot_commons.errors import octobot_commons.enums import octobot_commons.constants import octobot_commons.logging import octobot_trading.api import octobot_trading.constants import tentacles.Meta.DSL_operators.exchange_operators as exchange_operators import tentacles.Meta.DSL_operators.exchange_operators.exchange_public_data_operators.ohlcv_operators as ohlcv_operators from tentacles.Meta.DSL_operators.exchange_operators.tests import ( SYMBOL, TIME_FRAME, KLINE_SIGNATURE, historical_prices, historical_volume, historical_times, exchange_manager_with_candles, exchange_manager_with_candles_and_klines, exchange_manager_with_candles_and_new_candle_klines, candle_manager_by_time_frame_by_symbol, interpreter_with_candle_manager_by_time_frame_by_symbol, interpreter_with_exchange_manager_and_klines, interpreter_with_exchange_manager_and_new_candle_klines, interpreter, ) @pytest.fixture def expected_values(request, historical_prices, historical_volume, historical_times): select_value = request.param if select_value == "price": return historical_prices elif select_value == "volume": return historical_volume elif select_value == "time": return historical_times raise octobot_commons.errors.InvalidParametersError(f"Invalid select_value: {select_value}") @pytest.fixture def operator(request): return request.param @pytest.mark.asyncio @pytest.mark.parametrize("operator, expected_values", [ ("open", "price"), ("high", "price"), ("low", "price"), ("close", "price"), ("volume", "volume"), ("time", "time") ], indirect=True) # use indirect=True to pass fixtures as a parameter async def test_ohlcv_operators_basic_calls_without_klines( interpreter, interpreter_with_candle_manager_by_time_frame_by_symbol, operator, expected_values ): # test with both interpreter data sources for _interpreter in [interpreter, interpreter_with_candle_manager_by_time_frame_by_symbol]: # no param, use context values: SYMBOL, TIME_FRAME: BTC/USDT, 1h operator_value = await _interpreter.interprete(operator) assert np.array_equal(operator_value, expected_values) # ensure symbol parameters are used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}('ETH/USDT')"), expected_values / 2) # 1h ETH assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT')"), expected_values) # 1h BTC # ensure time frame is used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}(None, '4h')"), expected_values * 2) # 4h BTC assert np.array_equal(await _interpreter.interprete(f"{operator}(None, '1h')"), expected_values) # 1h BTC # ensure symbol and time frame are used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT', '1h')"), expected_values) # 4h BTC rsi value assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT', '4h')"), expected_values * 2) # 4h BTC rsi value assert np.array_equal(await _interpreter.interprete(f"{operator}('ETH/USDT', '1h')"), expected_values / 2) # 1h ETH rsi value with pytest.raises(KeyError): # no 4h ETH candles await _interpreter.interprete(f"{operator}('ETH/USDT', '4h')") def _adapted_for_kline(values: np.ndarray, operator: str, time_delay: float) -> np.ndarray: adapted = values.copy() if time_delay > 0: adapted = np.append(adapted[1:], adapted[-1] + (time_delay if operator == "time" else KLINE_SIGNATURE)) else: adapted[-1] += (0 if operator == "time" else KLINE_SIGNATURE) return adapted @pytest.mark.asyncio @pytest.mark.parametrize("operator, expected_values", [ ("open", "price"), ("high", "price"), ("low", "price"), ("close", "price"), ("volume", "volume"), ("time", "time") ], indirect=True) # use indirect=True to pass fixtures as a parameter async def test_ohlcv_operators_basic_calls_with_klines( interpreter_with_exchange_manager_and_klines, operator, expected_values ): # test with both interpreter data sources _interpreter = interpreter_with_exchange_manager_and_klines # no param, use context values: SYMBOL, TIME_FRAME: BTC/USDT, 1h operator_value = await _interpreter.interprete(operator) kline_adapted_value = _adapted_for_kline(expected_values, operator, 0) assert np.array_equal(operator_value, kline_adapted_value) # ensure symbol parameters are used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}('ETH/USDT')"), _adapted_for_kline(expected_values / 2, operator, 0)) # 1h ETH assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT')"), kline_adapted_value) # 1h BTC # ensure time frame is used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}(None, '4h')"), _adapted_for_kline(expected_values * 2, operator, 0)) # 4h BTC assert np.array_equal(await _interpreter.interprete(f"{operator}(None, '1h')"), kline_adapted_value) # 1h BTC # ensure symbol and time frame are used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT', '1h')"), kline_adapted_value) # 4h BTC rsi value assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT', '4h')"), _adapted_for_kline(expected_values * 2, operator, 0)) # 4h BTC rsi value assert np.array_equal(await _interpreter.interprete(f"{operator}('ETH/USDT', '1h')"), _adapted_for_kline(expected_values / 2, operator, 0)) # 1h ETH rsi value with pytest.raises(KeyError): # no 4h ETH candles await _interpreter.interprete(f"{operator}('ETH/USDT', '4h')") @pytest.mark.asyncio @pytest.mark.parametrize("operator, expected_values", [ ("open", "price"), ("high", "price"), ("low", "price"), ("close", "price"), ("volume", "volume"), ("time", "time") ], indirect=True) # use indirect=True to pass fixtures as a parameter async def test_ohlcv_operators_basic_calls_with_new_candle_klines( interpreter_with_exchange_manager_and_new_candle_klines, operator, expected_values ): # test with both interpreter data sources _interpreter = interpreter_with_exchange_manager_and_new_candle_klines # no param, use context values: SYMBOL, TIME_FRAME: BTC/USDT, 1h operator_value = await _interpreter.interprete(operator) one_hour_time_delay = octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames("1h")] * octobot_commons.constants.MINUTE_TO_SECONDS four_hours_time_delay = octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames("4h")] * octobot_commons.constants.MINUTE_TO_SECONDS kline_adapted_value = _adapted_for_kline(expected_values, operator, one_hour_time_delay) assert np.array_equal(operator_value, kline_adapted_value) # ensure symbol parameters are used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}('ETH/USDT')"), _adapted_for_kline(expected_values / 2, operator, one_hour_time_delay)) # 1h ETH assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT')"), kline_adapted_value) # 1h BTC # ensure time frame is used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}(None, '4h')"), _adapted_for_kline(expected_values * 2, operator, four_hours_time_delay)) # 4h BTC assert np.array_equal(await _interpreter.interprete(f"{operator}(None, '1h')"), kline_adapted_value) # 1h BTC # ensure symbol and time frame are used when provided assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT', '1h')"), kline_adapted_value) # 4h BTC rsi value assert np.array_equal(await _interpreter.interprete(f"{operator}('BTC/USDT', '4h')"), _adapted_for_kline(expected_values * 2, operator, four_hours_time_delay)) # 4h BTC rsi value assert np.array_equal(await _interpreter.interprete(f"{operator}('ETH/USDT', '1h')"), _adapted_for_kline(expected_values / 2, operator, one_hour_time_delay)) # 1h ETH rsi value with pytest.raises(KeyError): # no 4h ETH candles await _interpreter.interprete(f"{operator}('ETH/USDT', '4h')") # with unknown kline time: unknown kline is ignored def _get_kline(symbol_data, time_frame): kline = octobot_trading.api.get_symbol_klines(symbol_data, time_frame) kline[octobot_commons.enums.PriceIndexes.IND_PRICE_TIME.value] = 1000 return kline bot_log_mock = mock.Mock( error=mock.Mock() ) with mock.patch.object( ohlcv_operators, "_get_kline", side_effect=_get_kline ) as _get_kline_mock, mock.patch.object( octobot_commons.logging, "get_logger", mock.Mock(return_value=bot_log_mock) ): operator_value = await _interpreter.interprete(operator) _get_kline_mock.assert_called_once() # not == kline adapted value because unknown kline is ignored assert np.array_equal(operator_value, kline_adapted_value) is False assert np.array_equal(operator_value, expected_values) bot_log_mock.error.assert_called_once() assert "kline time (1000) is not equal to last candle time not the last time" in bot_log_mock.error.call_args[0][0] @pytest.mark.asyncio @pytest.mark.parametrize("operator", [ ("open"), ("high"), ("low"), ("close"), ("volume"), ("time") ]) async def test_ohlcv_operators_dependencies(interpreter, operator, exchange_manager_with_candles): interpreter.prepare(f"{operator}") assert interpreter.get_dependencies() == [ exchange_operators.ExchangeDataDependency( exchange_manager_id=octobot_trading.api.get_exchange_manager_id(exchange_manager_with_candles), symbol=SYMBOL, time_frame=TIME_FRAME, data_source=octobot_trading.constants.OHLCV_CHANNEL ) ] # same dependency for all operators interpreter.prepare(f"{operator} + close + volume") assert interpreter.get_dependencies() == [ exchange_operators.ExchangeDataDependency( exchange_manager_id=octobot_trading.api.get_exchange_manager_id(exchange_manager_with_candles), symbol=SYMBOL, time_frame=TIME_FRAME, data_source=octobot_trading.constants.OHLCV_CHANNEL ) ] # SYMBOL + ETH/USDT dependency # => dynamic dependencies are not yet supported. Update this test when supported. interpreter.prepare(f"{operator} + close('ETH/USDT') + volume") assert interpreter.get_dependencies() == [ exchange_operators.ExchangeDataDependency( exchange_manager_id=octobot_trading.api.get_exchange_manager_id(exchange_manager_with_candles), symbol=SYMBOL, time_frame=TIME_FRAME, data_source=octobot_trading.constants.OHLCV_CHANNEL ), # not identified as a dependency # exchange_operators.ExchangeDataDependency( # exchange_manager_id=octobot_trading.api.get_exchange_manager_id(exchange_manager_with_candles), # symbol="ETH/USDT", # time_frame=TIME_FRAME, # data_source=octobot_trading.constants.OHLCV_CHANNEL # ), ] ================================================ FILE: Meta/DSL_operators/exchange_operators/tests/test_mocks.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import numpy as np import octobot_commons.enums import octobot_commons.constants from tentacles.Meta.DSL_operators.exchange_operators.tests import ( historical_prices, historical_volume, historical_times, KLINE_SIGNATURE, TIME_FRAME, exchange_manager_with_candles, exchange_manager_with_candles_and_klines, exchange_manager_with_candles_and_new_candle_klines, candle_manager_by_time_frame_by_symbol, interpreter, interpreter_with_candle_manager_by_time_frame_by_symbol, interpreter_with_exchange_manager_and_new_candle_klines, interpreter_with_exchange_manager_and_klines ) @pytest.mark.asyncio async def test_interpreter_mock(interpreter, historical_prices, historical_volume, historical_times): assert np.array_equal(await interpreter.interprete("open"), historical_prices) assert await interpreter.interprete("open[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter.interprete("high"), historical_prices) assert await interpreter.interprete("high[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter.interprete("low"), historical_prices) assert await interpreter.interprete("low[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter.interprete("close"), historical_prices) assert await interpreter.interprete("close[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter.interprete("volume"), historical_volume) assert await interpreter.interprete("volume[-1]") == historical_volume[-1] == 1113 assert np.array_equal(await interpreter.interprete("time"), historical_times) assert await interpreter.interprete("time[-1]") == historical_times[-1] == 41 @pytest.mark.asyncio async def test_interpreter_with_exchange_manager_and_klines_mock( interpreter_with_exchange_manager_and_klines, historical_prices, historical_volume, historical_times ): kline_adapted_historical_prices = historical_prices.copy() kline_adapted_historical_prices[-1] += KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_klines.interprete("open"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_klines.interprete("open[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_klines.interprete("high"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_klines.interprete("high[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_klines.interprete("low"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_klines.interprete("low[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_klines.interprete("close"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_klines.interprete("close[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE kline_adapted_historical_volume = historical_volume.copy() kline_adapted_historical_volume[-1] += KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_klines.interprete("volume"), kline_adapted_historical_volume) assert await interpreter_with_exchange_manager_and_klines.interprete("volume[-1]") == historical_volume[-1] + KLINE_SIGNATURE == 1113 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_klines.interprete("time"), historical_times) assert await interpreter_with_exchange_manager_and_klines.interprete("time[-1]") == historical_times[-1] == 41 @pytest.mark.asyncio async def test_interpreter_with_exchange_manager_and_new_candle_klines_mock( interpreter_with_exchange_manager_and_new_candle_klines, historical_prices, historical_volume, historical_times ): kline_adapted_historical_prices = np.append(historical_prices[1:], historical_prices[-1] + KLINE_SIGNATURE) assert len(historical_prices) == len(kline_adapted_historical_prices) assert np.array_equal(await interpreter_with_exchange_manager_and_new_candle_klines.interprete("open"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_new_candle_klines.interprete("open[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_new_candle_klines.interprete("high"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_new_candle_klines.interprete("high[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_new_candle_klines.interprete("low"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_new_candle_klines.interprete("low[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE assert np.array_equal(await interpreter_with_exchange_manager_and_new_candle_klines.interprete("close"), kline_adapted_historical_prices) assert await interpreter_with_exchange_manager_and_new_candle_klines.interprete("close[-1]") == kline_adapted_historical_prices[-1] == 92.22 + KLINE_SIGNATURE kline_adapted_historical_volume = np.append(historical_volume[1:], historical_volume[-1] + KLINE_SIGNATURE) assert np.array_equal(await interpreter_with_exchange_manager_and_new_candle_klines.interprete("volume"), kline_adapted_historical_volume) assert await interpreter_with_exchange_manager_and_new_candle_klines.interprete("volume[-1]") == historical_volume[-1] + KLINE_SIGNATURE == 1113 + KLINE_SIGNATURE new_kline_time = historical_times[-1] + octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames(TIME_FRAME)] * octobot_commons.constants.MINUTE_TO_SECONDS kline_adapted_historical_times = np.append(historical_times[1:], new_kline_time) assert np.array_equal(await interpreter_with_exchange_manager_and_new_candle_klines.interprete("time"), kline_adapted_historical_times) assert await interpreter_with_exchange_manager_and_new_candle_klines.interprete("time[-1]") == kline_adapted_historical_times[-1] == new_kline_time @pytest.mark.asyncio async def test_interpreter_with_candle_manager_by_time_frame_by_symbol_mock( interpreter_with_candle_manager_by_time_frame_by_symbol, historical_prices, historical_volume, historical_times ): assert np.array_equal(await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("open"), historical_prices) assert await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("open[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("high"), historical_prices) assert await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("high[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("low"), historical_prices) assert await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("low[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("close"), historical_prices) assert await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("close[-1]") == historical_prices[-1] == 92.22 assert np.array_equal(await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("volume"), historical_volume) assert await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("volume[-1]") == historical_volume[-1] == 1113 assert np.array_equal(await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("time"), historical_times) assert await interpreter_with_candle_manager_by_time_frame_by_symbol.interprete("time[-1]") == historical_times[-1] == 41 ================================================ FILE: Meta/DSL_operators/python_std_operators/__init__.py ================================================ # pylint: disable=R0801 # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.DSL_operators.python_std_operators.base_binary_operators as dsl_interpreter_base_binary_operators from tentacles.Meta.DSL_operators.python_std_operators.base_binary_operators import ( AddOperator, SubOperator, MultOperator, DivOperator, FloorDivOperator, ModOperator, PowOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_compare_operators as dsl_interpreter_base_compare_operators from tentacles.Meta.DSL_operators.python_std_operators.base_compare_operators import ( EqOperator, NotEqOperator, LtOperator, LtEOperator, GtOperator, GtEOperator, IsOperator, IsNotOperator, InOperator, NotInOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_unary_operators as dsl_interpreter_base_unary_operators from tentacles.Meta.DSL_operators.python_std_operators.base_unary_operators import ( UAddOperator, USubOperator, NotOperator, InvertOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_nary_operators as dsl_interpreter_base_nary_operators from tentacles.Meta.DSL_operators.python_std_operators.base_nary_operators import ( AndOperator, OrOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_call_operators as dsl_interpreter_base_call_operators from tentacles.Meta.DSL_operators.python_std_operators.base_call_operators import ( MinOperator, MaxOperator, MeanOperator, SqrtOperator, AbsOperator, RoundOperator, FloorOperator, CeilOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_name_operators as dsl_interpreter_base_name_operators from tentacles.Meta.DSL_operators.python_std_operators.base_name_operators import ( PiOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_expression_operators as dsl_interpreter_base_expression_operators from tentacles.Meta.DSL_operators.python_std_operators.base_expression_operators import ( IfExpOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_subscripting_operators as dsl_interpreter_base_subscripting_operators from tentacles.Meta.DSL_operators.python_std_operators.base_subscripting_operators import ( SubscriptOperator, SliceOperator, ) import tentacles.Meta.DSL_operators.python_std_operators.base_iterable_operators as dsl_interpreter_base_iterable_operators from tentacles.Meta.DSL_operators.python_std_operators.base_iterable_operators import ( ListOperator, ) __all__ = [ "AddOperator", "SubOperator", "MultOperator", "DivOperator", "FloorDivOperator", "ModOperator", "PowOperator", "EqOperator", "NotEqOperator", "LtOperator", "LtEOperator", "GtOperator", "GtEOperator", "IsOperator", "IsNotOperator", "InOperator", "NotInOperator", "UAddOperator", "USubOperator", "NotOperator", "InvertOperator", "AndOperator", "OrOperator", "MinOperator", "MaxOperator", "MeanOperator", "SqrtOperator", "AbsOperator", "RoundOperator", "FloorOperator", "CeilOperator", "PiOperator", "IfExpOperator", "SubscriptOperator", "SliceOperator", "ListOperator", ] ================================================ FILE: Meta/DSL_operators/python_std_operators/base_binary_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import octobot_commons.dsl_interpreter.operators.binary_operator as dsl_interpreter_binary_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class AddOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "+" DESCRIPTION = "Addition operator. Adds two operands together." EXAMPLE = "5 + 3" @staticmethod def get_name() -> str: return ast.Add.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left + right class SubOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "-" DESCRIPTION = "Subtraction operator. Subtracts the right operand from the left operand." EXAMPLE = "5 - 3" @staticmethod def get_name() -> str: return ast.Sub.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left - right class MultOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "*" DESCRIPTION = "Multiplication operator. Multiplies two operands." EXAMPLE = "5 * 3" @staticmethod def get_name() -> str: return ast.Mult.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left * right class DivOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "/" DESCRIPTION = "Division operator. Divides the left operand by the right operand." EXAMPLE = "10 / 2" @staticmethod def get_name() -> str: return ast.Div.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left / right class FloorDivOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "//" DESCRIPTION = "Floor division operator. Divides the left operand by the right operand and returns the floor of the result." EXAMPLE = "10 // 3" @staticmethod def get_name() -> str: return ast.FloorDiv.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left // right class ModOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "%" DESCRIPTION = "Modulo operator. Returns the remainder after dividing the left operand by the right operand." EXAMPLE = "10 % 3" @staticmethod def get_name() -> str: return ast.Mod.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left % right class PowOperator(dsl_interpreter_binary_operator.BinaryOperator): NAME = "**" DESCRIPTION = "Exponentiation operator. Raises the left operand to the power of the right operand." EXAMPLE = "2 ** 3" @staticmethod def get_name() -> str: return ast.Pow.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left**right ================================================ FILE: Meta/DSL_operators/python_std_operators/base_call_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import math import octobot_commons.errors import octobot_commons.dsl_interpreter as dsl_interpreter class MinOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 NAME = "min" DESCRIPTION = "Returns the minimum value from the given operands." EXAMPLE = "min(1, 2, 3)" @staticmethod def get_name() -> str: return "min" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() return min(operands) class MaxOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 NAME = "max" DESCRIPTION = "Returns the maximum value from the given operands." EXAMPLE = "max(1, 2, 3)" @staticmethod def get_name() -> str: return "max" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() return max(operands) class MeanOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 NAME = "mean" DESCRIPTION = "Returns the arithmetic mean (average) of the given numeric operands." EXAMPLE = "mean(1, 2, 3, 4)" @staticmethod def get_name() -> str: return "mean" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() # Ensure all operands are numeric numeric_operands = [] for operand in operands: if isinstance(operand, (int, float)): numeric_operands.append(operand) else: raise octobot_commons.errors.InvalidParametersError( f"mean() requires numeric arguments, got {type(operand).__name__}" ) return sum(numeric_operands) / len(numeric_operands) class SqrtOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 MAX_PARAMS = 1 NAME = "sqrt" DESCRIPTION = "Returns the square root of the given numeric operand." EXAMPLE = "sqrt(16)" @staticmethod def get_name() -> str: return "sqrt" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: computed_parameters = self.get_computed_parameters() operand = computed_parameters[0] if isinstance(operand, (int, float)): return math.sqrt(operand) raise octobot_commons.errors.InvalidParametersError( f"sqrt() requires a numeric argument, got {type(operand).__name__}" ) class AbsOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 MAX_PARAMS = 1 NAME = "abs" DESCRIPTION = "Returns the absolute value of the given operand." EXAMPLE = "abs(-5)" @staticmethod def get_name() -> str: return "abs" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: computed_parameters = self.get_computed_parameters() operand = computed_parameters[0] return abs(operand) class RoundOperator(dsl_interpreter.CallOperator): NAME = "round" DESCRIPTION = "Rounds the given numeric value to the specified number of decimal digits. If digits is not provided, rounds to the nearest integer." EXAMPLE = "round(3.14159, 2)" @staticmethod def get_name() -> str: return "round" @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="value", description="the value to round", required=True, type=list), dsl_interpreter.OperatorParameter(name="digits", description="the number of digits to round to", required=False, type=int), ] def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: computed_parameters = self.get_computed_parameters() operand = computed_parameters[0] digits = int(computed_parameters[1]) if len(computed_parameters) == 2 else 0 if isinstance(operand, (int, float)): return round(operand, digits) raise octobot_commons.errors.InvalidParametersError( f"round() requires a numeric argument, got {type(operand).__name__}" ) class FloorOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 MAX_PARAMS = 1 NAME = "floor" DESCRIPTION = "Returns the floor of the given numeric operand (largest integer less than or equal to the value)." EXAMPLE = "floor(3.7)" @staticmethod def get_name() -> str: return "floor" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: computed_parameters = self.get_computed_parameters() operand = computed_parameters[0] if isinstance(operand, (int, float)): return math.floor(operand) raise octobot_commons.errors.InvalidParametersError( f"floor() requires a numeric argument, got {type(operand).__name__}" ) class CeilOperator(dsl_interpreter.CallOperator): MIN_PARAMS = 1 MAX_PARAMS = 1 NAME = "ceil" DESCRIPTION = "Returns the ceiling of the given numeric operand (smallest integer greater than or equal to the value)." EXAMPLE = "ceil(3.2)" @staticmethod def get_name() -> str: return "ceil" def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: computed_parameters = self.get_computed_parameters() operand = computed_parameters[0] if isinstance(operand, (int, float)): return math.ceil(operand) raise octobot_commons.errors.InvalidParametersError( f"ceil() requires a numeric argument, got {type(operand).__name__}" ) ================================================ FILE: Meta/DSL_operators/python_std_operators/base_compare_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import octobot_commons.dsl_interpreter.operators.compare_operator as dsl_interpreter_compare_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class EqOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "==" DESCRIPTION = "Equality operator. Returns True if the left operand equals the right operand." EXAMPLE = "5 == 5" @staticmethod def get_name() -> str: return ast.Eq.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left == right class NotEqOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "!=" DESCRIPTION = "Inequality operator. Returns True if the left operand does not equal the right operand." EXAMPLE = "5 != 3" @staticmethod def get_name() -> str: return ast.NotEq.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left != right class LtOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "<" DESCRIPTION = "Less than operator. Returns True if the left operand is less than the right operand." EXAMPLE = "3 < 5" @staticmethod def get_name() -> str: return ast.Lt.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left < right class LtEOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "<=" DESCRIPTION = "Less than or equal operator. Returns True if the left operand is less than or equal to the right operand." EXAMPLE = "5 <= 5" @staticmethod def get_name() -> str: return ast.LtE.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left <= right class GtOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = ">" DESCRIPTION = "Greater than operator. Returns True if the left operand is greater than the right operand." EXAMPLE = "5 > 3" @staticmethod def get_name() -> str: return ast.Gt.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left > right class GtEOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = ">=" DESCRIPTION = "Greater than or equal operator. Returns True if the left operand is greater than or equal to the right operand." EXAMPLE = "5 >= 5" @staticmethod def get_name() -> str: return ast.GtE.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left >= right class IsOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "is" DESCRIPTION = "Identity operator. Returns True if the left operand is the same object as the right operand." EXAMPLE = "x is None" @staticmethod def get_name() -> str: return ast.Is.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left is right class IsNotOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "is not" DESCRIPTION = "Negated identity operator. Returns True if the left operand is not the same object as the right operand." EXAMPLE = "x is not None" @staticmethod def get_name() -> str: return ast.IsNot.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left is not right class InOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "in" DESCRIPTION = "Membership operator. Returns True if the left operand is found in the right operand (container)." EXAMPLE = "3 in [1, 2, 3]" @staticmethod def get_name() -> str: return ast.In.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left in right class NotInOperator(dsl_interpreter_compare_operator.CompareOperator): NAME = "not in" DESCRIPTION = "Negated membership operator. Returns True if the left operand is not found in the right operand (container)." EXAMPLE = "4 not in [1, 2, 3]" @staticmethod def get_name() -> str: return ast.NotIn.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: left, right = self.get_computed_left_and_right_parameters() return left not in right ================================================ FILE: Meta/DSL_operators/python_std_operators/base_expression_operators.py ================================================ # pylint: disable=missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import octobot_commons.dsl_interpreter.operators.expression_operator as dsl_interpreter_expression_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class IfExpOperator(dsl_interpreter_expression_operator.ExpressionOperator): """ Base class for if expression operators: a if b else c If expression operators have three operands: condition, true expression, false expression. """ NAME = "if ... else" DESCRIPTION = "Conditional expression operator. Returns the body expression if the test condition is True, otherwise returns the orelse expression." EXAMPLE = "5 if True else 3" def __init__( self, test: dsl_interpreter_operator.OperatorParameterType, body: dsl_interpreter_operator.OperatorParameterType, orelse: dsl_interpreter_operator.OperatorParameterType, ): super().__init__(test, body, orelse) self.test = test self.body = body self.orelse = orelse @staticmethod def get_name() -> str: return ast.IfExp.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: # Compute the test condition test_value = ( self.test.compute() if isinstance(self.test, dsl_interpreter_operator.Operator) else self.test ) # Evaluate the condition (truthy check) if test_value: # Return body if condition is True return ( self.body.compute() if isinstance(self.body, dsl_interpreter_operator.Operator) else self.body ) # Return orelse if condition is False return ( self.orelse.compute() if isinstance(self.orelse, dsl_interpreter_operator.Operator) else self.orelse ) ================================================ FILE: Meta/DSL_operators/python_std_operators/base_iterable_operators.py ================================================ # pylint: disable=missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import octobot_commons.dsl_interpreter.operators.iterable_operator as dsl_interpreter_iterable_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class ListOperator(dsl_interpreter_iterable_operator.IterableOperator): """ List operator: [1, 2, 3] List operator have one or more operands. """ NAME = "[...]" DESCRIPTION = "List constructor operator. Creates a list from the given operands." EXAMPLE = "[1, 2, 3]" @staticmethod def get_name() -> str: return ast.List.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: # Compute the test condition return list(self.get_computed_parameters()) ================================================ FILE: Meta/DSL_operators/python_std_operators/base_name_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import math import octobot_commons.dsl_interpreter.operators.name_operator as dsl_interpreter_name_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class PiOperator(dsl_interpreter_name_operator.NameOperator): MAX_PARAMS = 0 NAME = "pi" DESCRIPTION = "Mathematical constant pi (π), approximately 3.14159." EXAMPLE = "pi" @staticmethod def get_name() -> str: return "pi" def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: return math.pi class NaNOperator(dsl_interpreter_name_operator.NameOperator): MAX_PARAMS = 0 NAME = "nan" DESCRIPTION = "Not a Number constant. Represents an undefined or unrepresentable numeric value." EXAMPLE = "nan" @staticmethod def get_name() -> str: return "nan" def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: return float("nan") ================================================ FILE: Meta/DSL_operators/python_std_operators/base_nary_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import octobot_commons.dsl_interpreter.operators.n_ary_operator as dsl_interpreter_n_ary_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class AndOperator(dsl_interpreter_n_ary_operator.NaryOperator): MIN_PARAMS = 1 MAX_PARAMS = None NAME = "and" DESCRIPTION = "Logical AND operator. Returns True if all operands are truthy, otherwise returns False." EXAMPLE = "True and False" @staticmethod def get_name() -> str: return ast.And.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: operands = self.get_computed_parameters() return all(operands) class OrOperator(dsl_interpreter_n_ary_operator.NaryOperator): MIN_PARAMS = 1 MAX_PARAMS = None NAME = "or" DESCRIPTION = "Logical OR operator. Returns True if any operand is truthy, otherwise returns False." EXAMPLE = "True or False" @staticmethod def get_name() -> str: return ast.Or.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: operands = self.get_computed_parameters() return any(operands) ================================================ FILE: Meta/DSL_operators/python_std_operators/base_subscripting_operators.py ================================================ # pylint: disable=missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import numpy as np import typing import octobot_commons.errors import octobot_commons.dsl_interpreter.operators.subscripting_operator as dsl_interpreter_subscripting_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class SubscriptOperator(dsl_interpreter_subscripting_operator.SubscriptingOperator): """ Base class for subscripting operators: array[index] Subscripting operators have three operands: the array/list, the index or slice and the context. """ NAME = "[...]" DESCRIPTION = "Subscripting operator. Accesses an element from a list or array using an index." EXAMPLE = "my_list[0]" def __init__( self, array_or_list: dsl_interpreter_operator.OperatorParameterType, index_or_slice: dsl_interpreter_operator.OperatorParameterType, context: dsl_interpreter_operator.OperatorParameterType, **kwargs: typing.Any ): """ Initialize the subscripting operator with its array, index and context. """ super().__init__(array_or_list, index_or_slice, context, **kwargs) def get_computed_array_or_list_and_index_or_slice_and_context_parameters( self, ) -> typing.Tuple[ dsl_interpreter_operator.ComputedOperatorParameterType, dsl_interpreter_operator.ComputedOperatorParameterType, dsl_interpreter_operator.ComputedOperatorParameterType, ]: """ Get the computed array/list, index/slice and context of the subscripting operator. """ computed_parameters = self.get_computed_parameters() if len(computed_parameters) != 3: raise octobot_commons.errors.InvalidParametersError(f"Unsupported {self.__class__.__name__}: expected three parameters, got {len(computed_parameters)}") if not isinstance(computed_parameters, (list, tuple, np.ndarray)): raise octobot_commons.errors.InvalidParametersError(f"Unsupported {self.__class__.__name__} computed parameters 1 type: {type(computed_parameters).__name__}") return computed_parameters[0], computed_parameters[1], computed_parameters[2] @staticmethod def get_name() -> str: return ast.Subscript.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: # Compute the test condition array_or_list, index, context = self.get_computed_array_or_list_and_index_or_slice_and_context_parameters() if isinstance(context, ast.Load): return array_or_list[index] raise octobot_commons.errors.InvalidParametersError(f"Unsupported {self.__class__.__name__} context type: {type(context).__name__}") class SliceOperator(dsl_interpreter_subscripting_operator.SubscriptingOperator): """ Operator for creating slice objects: slice(lower, upper, step) Used for array slicing like array[start:stop:step] """ NAME = "[start:stop:step]" DESCRIPTION = "Slice operator. Creates a slice object for array/list slicing with optional start, stop, and step parameters." EXAMPLE = "my_list[1:5:2]" @staticmethod def get_name() -> str: return ast.Slice.__name__ def get_computed_lower_and_upper_and_step_parameters( self, ) -> typing.Tuple[ dsl_interpreter_operator.ComputedOperatorParameterType, dsl_interpreter_operator.ComputedOperatorParameterType, dsl_interpreter_operator.ComputedOperatorParameterType, ]: """ Get the computed lower, upper and step of the slice operator. """ computed_parameters = self.get_computed_parameters() if len(computed_parameters) > 3: raise octobot_commons.errors.InvalidParametersError(f"Unsupported {self.__class__.__name__}: expected at most three parameters, got {len(computed_parameters)}") lower = int(computed_parameters[0]) if len(computed_parameters) > 0 and computed_parameters[0] is not None else None upper = int(computed_parameters[1]) if len(computed_parameters) > 1 and computed_parameters[1] is not None else None step = int(computed_parameters[2]) if len(computed_parameters) > 2 and computed_parameters[2] is not None else None return lower, upper, step def compute(self) -> slice: """ Compute and return a Python slice object. """ maybe_lower, maybe_upper, maybe_step = self.get_computed_lower_and_upper_and_step_parameters() if maybe_lower is not None: if maybe_upper is not None: if maybe_step is not None: return slice(maybe_lower, maybe_upper, maybe_step) return slice(maybe_lower, maybe_upper, None) return slice(maybe_lower, None, None) if maybe_upper is not None: return slice(None, maybe_upper, None) return slice(None, None, None) ================================================ FILE: Meta/DSL_operators/python_std_operators/base_unary_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ast import octobot_commons.dsl_interpreter.operators.unary_operator as dsl_interpreter_unary_operator import octobot_commons.dsl_interpreter.operator as dsl_interpreter_operator class UAddOperator(dsl_interpreter_unary_operator.UnaryOperator): NAME = "+" DESCRIPTION = "Unary plus operator. Returns the operand unchanged (mainly for symmetry with unary minus)." EXAMPLE = "+5" @staticmethod def get_name() -> str: return ast.UAdd.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: operand = self.get_computed_operand() return +operand class USubOperator(dsl_interpreter_unary_operator.UnaryOperator): NAME = "-" DESCRIPTION = "Unary minus operator. Negates the operand (multiplies by -1)." EXAMPLE = "-5" @staticmethod def get_name() -> str: return ast.USub.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: operand = self.get_computed_operand() return -operand class NotOperator(dsl_interpreter_unary_operator.UnaryOperator): NAME = "not" DESCRIPTION = "Logical NOT operator. Returns True if the operand is falsy, False if it is truthy." EXAMPLE = "not True" @staticmethod def get_name() -> str: return ast.Not.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: operand = self.get_computed_operand() return not operand class InvertOperator(dsl_interpreter_unary_operator.UnaryOperator): NAME = "~" DESCRIPTION = "Bitwise NOT operator. Inverts all bits of the operand. In this implementation, it behaves as logical NOT." EXAMPLE = "~True" @staticmethod def get_name() -> str: return ast.Invert.__name__ def compute(self) -> dsl_interpreter_operator.ComputedOperatorParameterType: operand = self.get_computed_operand() return not operand # ~operand has been deprecated in favor of "not" # return ~operand ================================================ FILE: Meta/DSL_operators/python_std_operators/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": [], "tentacles-requirements": [] } ================================================ FILE: Meta/DSL_operators/python_std_operators/tests/test_base_operators.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import math import pytest import octobot_commons.dsl_interpreter as dsl_interpreter import octobot_commons.errors @pytest.fixture def interpreter(): return dsl_interpreter.Interpreter(dsl_interpreter.get_all_operators()) @pytest.mark.asyncio async def test_interpreter_basic_operations(interpreter): # constants assert await interpreter.interprete("True") is True assert await interpreter.interprete("'test'") == "test" assert await interpreter.interprete('"test"') == "test" # unary operators assert await interpreter.interprete("1") == 1 assert await interpreter.interprete("-11") == -11 assert await interpreter.interprete("+11") == +11 assert await interpreter.interprete("not True") is False assert await interpreter.interprete("~ False") is True # binary operators assert await interpreter.interprete("1 + 2") == 3 assert await interpreter.interprete("1 - 2") == -1 assert await interpreter.interprete("4 * 2") == 8 assert await interpreter.interprete("1 / 2") == 0.5 assert await interpreter.interprete("1 % 3") == 1 assert await interpreter.interprete("1 // 2") == 0 assert await interpreter.interprete("3 ** 2") == 9 # compare operators assert await interpreter.interprete("1 < 2") is True assert await interpreter.interprete("1 <= 2") is True assert await interpreter.interprete("2 <= 2") is True assert await interpreter.interprete("1 > 2") is False assert await interpreter.interprete("2 >= 2") is True assert await interpreter.interprete("1 == 2") is False assert await interpreter.interprete("1 != 2") is True assert await interpreter.interprete("1 is 2") is False assert await interpreter.interprete("1 is not 2") is True assert await interpreter.interprete("'1' in '123'") is True assert await interpreter.interprete("'4' in '123'") is False assert await interpreter.interprete("1 in [1, 2, 3]") is True assert await interpreter.interprete("4 in [1, 2, 3]") is False assert await interpreter.interprete("1 not in [1, 2, 3]") is False assert await interpreter.interprete("4 not in [1, 2, 3]") is True # variables assert await interpreter.interprete("pi") == math.pi assert await interpreter.interprete("pi + 1") == math.pi + 1 assert math.isnan(await interpreter.interprete("nan")) assert math.isnan(await interpreter.interprete("nan + 1")) # expressions assert await interpreter.interprete("1 if True else 2") == 1 assert await interpreter.interprete("1 if False else 2") == 2 assert await interpreter.interprete("1 if 1 < 2 else 2") == 1 assert await interpreter.interprete("1 if 1 > 2 else 2") == 2 assert await interpreter.interprete("1 if 1 == 1 else 2") == 1 assert await interpreter.interprete("1 if 1 != 2 else 2") == 1 assert await interpreter.interprete("1 if 1 is 1 else 2") == 1 assert await interpreter.interprete("1 if 1 is not 2 else 2") == 1 # subscripting operators assert await interpreter.interprete("[1, 2, 3][:]") == [1, 2, 3] assert await interpreter.interprete("[1, 2, 3][0]") == 1 assert await interpreter.interprete("[1, 2, 3][0:2]") == [1, 2] assert await interpreter.interprete("[1, 2, 3][2:]") == [3] assert await interpreter.interprete("[1, 2, 3][:1]") == [1] assert await interpreter.interprete("[1, 2, 3][:-1]") == [1, 2] assert await interpreter.interprete("[1, 2, 3][-1]") == 3 assert await interpreter.interprete("[1, 2, 3, 4, 5, 6][0:6:2]") == [1, 3, 5] @pytest.mark.asyncio async def test_interpreter_mixed_basic_operations(interpreter): assert await interpreter.interprete("1 + 2 * 3") == 7 assert await interpreter.interprete("(1 + 2) * 3") == 9 assert await interpreter.interprete("(1 + 2) * 3 + 5 / 2 + 10") == 21.5 assert await interpreter.interprete("(1 + 2) * 3 if 1 < 2 else 10 + pi") == 9 assert await interpreter.interprete("(1 + 2) * 3 if 1 > 2 else 10 + pi") == 10 + math.pi assert await interpreter.interprete("1 < 2 and 2 < 3") is True assert await interpreter.interprete("1 < 2 and 2 < 3 and True and 1") is True assert await interpreter.interprete("1 < 2 and 2 > 3") is False assert await interpreter.interprete("1 < 2 or 2 > 3") is True assert await interpreter.interprete("1 < 2 or 2 > 3 or True or False or 0") is True assert await interpreter.interprete("1 > 2 or 2 > 3") is False assert await interpreter.interprete("not (1 < 2 and 2 < 3)") is False assert await interpreter.interprete("not (1 < 2 and 2 > 3)") is True assert await interpreter.interprete("not (1 > 2 or 2 > 3)") is True assert await interpreter.interprete("not (1 > 2 or 2 < 3)") is False @pytest.mark.asyncio async def test_interpreter_call_operations(interpreter): assert await interpreter.interprete("max(1, 2, 3)") == 3 assert await interpreter.interprete("min(1, 2, 3)") == 1 assert await interpreter.interprete("abs(-1)") == 1 assert await interpreter.interprete("abs(1)") == 1 assert await interpreter.interprete("sqrt(4)") == 2 assert await interpreter.interprete("mean(1, 2, 3)") == 2 assert await interpreter.interprete("mean(50, 110.2)") == 80.1 assert await interpreter.interprete("mean(3)") == 3 assert await interpreter.interprete("round(1.23456789, 2)") == 1.23 assert await interpreter.interprete("round(1.23456789, 2.22)") == 1.23 assert await interpreter.interprete("round(1.23456789)") == 1 assert await interpreter.interprete("floor(1.23456789)") == 1 assert await interpreter.interprete("ceil(1.23456789)") == 2 @pytest.mark.asyncio async def test_interpreter_mixed_call_and_basic_operations(interpreter): assert await interpreter.interprete("max(sqrt(9), abs(-4), 3 + 6)") == 9 assert await interpreter.interprete("min(sqrt(9), abs(-4), 3 + 6)") == 3 assert await interpreter.interprete("abs(min(sqrt(9), abs(-4), 3 + 6))") == 3 assert await interpreter.interprete("sqrt(max(1, 2, 3, 4))") == 2 assert await interpreter.interprete("sqrt(2**2)") == 2 assert await interpreter.interprete("sqrt(min(1, 2, 3))") == 1 assert await interpreter.interprete("abs(sqrt(max(1, 2, 4)))") == 2 assert await interpreter.interprete("abs(sqrt(min(1, 2, 4)))") == 1 assert await interpreter.interprete("mean(4, 5) + 1 + mean(1, 1 + 1, 3)") == 7.5 @pytest.mark.asyncio async def test_interpreter_insupported_operations(interpreter): with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("1 & 2") with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("1 | 2") with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("3 ^ 2") with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("1 << 2") with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("1 >> 2") with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("my_variable") with pytest.raises(octobot_commons.errors.UnsupportedOperatorError): await interpreter.interprete("unknown_operator(1)") with pytest.raises(octobot_commons.errors.InvalidParametersError): await interpreter.interprete("mean(1, 'a')") with pytest.raises(octobot_commons.errors.InvalidParametersError): await interpreter.interprete("mean()") ================================================ FILE: Meta/DSL_operators/python_std_operators/tests/test_dictionnaries.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.constants import octobot_commons.dsl_interpreter @pytest.mark.parametrize( "libraries", [tuple(), (octobot_commons.constants.BASE_OPERATORS_LIBRARY, )] ) def test_get_all_operators(libraries): assert octobot_commons.dsl_interpreter.get_all_operators(*libraries) is not None assert len(octobot_commons.dsl_interpreter.get_all_operators(*libraries)) > 0 operators = octobot_commons.dsl_interpreter.get_all_operators(*libraries) operator_types = [ octobot_commons.dsl_interpreter.BinaryOperator, octobot_commons.dsl_interpreter.UnaryOperator, octobot_commons.dsl_interpreter.CompareOperator, octobot_commons.dsl_interpreter.NaryOperator, octobot_commons.dsl_interpreter.CallOperator, octobot_commons.dsl_interpreter.NameOperator, ] operator_by_type = { operator_type.__name__: [] for operator_type in operator_types } for operator in operators: name = operator.get_name() assert len(name) > 0 for operator_type in operator_types: if issubclass(operator, operator_type): operator_by_type[operator_type.__name__].append(operator) break for operator_type, operators in operator_by_type.items(): assert len(operators) > 1, f"Expected at least 2 {operator_type} operators. {operator_by_type=}" ================================================ FILE: Meta/DSL_operators/ta_operators/__init__.py ================================================ # pylint: disable=R0801 # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.DSL_operators.ta_operators.tulipy_technical_analysis_operators as tulipy_technical_analysis_operators from tentacles.Meta.DSL_operators.ta_operators.tulipy_technical_analysis_operators import ( RSIOperator, ) __all__ = [ "RSIOperator", ] ================================================ FILE: Meta/DSL_operators/ta_operators/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": [], "tentacles-requirements": [] } ================================================ FILE: Meta/DSL_operators/ta_operators/ta_operator.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.dsl_interpreter.operators.call_operator as dsl_interpreter_call_operator TA_LIBRARY = "ta" class TAOperator(dsl_interpreter_call_operator.CallOperator): @staticmethod def get_library() -> str: """ Get the library of the operator. """ return TA_LIBRARY ================================================ FILE: Meta/DSL_operators/ta_operators/tests/test_docs_examples.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest from tentacles.Meta.DSL_operators.exchange_operators.tests import ( historical_prices, historical_volume, historical_times, exchange_manager_with_candles, interpreter, ) @pytest.mark.asyncio async def test_mm_formulas_docs_examples(interpreter): # ensure examples in the docs are working (meaning returning a parsable number) assert round(await interpreter.interprete("close[-1]"), 2) == 92.22 assert round(await interpreter.interprete("open[-1]"), 2) == 92.22 assert round(await interpreter.interprete("high[-3]"), 2) == 92.92 assert round(await interpreter.interprete("low[-1]"), 2) == 92.22 assert round(await interpreter.interprete("volume[-2]"), 2) == 1211 assert round(await interpreter.interprete("time[-1]"), 2) == 41 assert round(await interpreter.interprete("ma(close, 12)[-1]"), 2) == 92.95 assert round(await interpreter.interprete("ema(open, 24)[-1]"), 2) == 90.21 assert round(await interpreter.interprete("vwma(close, volume, 4)[-1]"), 2) == 92.54 assert round(await interpreter.interprete("rsi(close, 14)[-1]"), 2) == 67.55 assert round(await interpreter.interprete("max(close[-1], open[-1])"), 2) == 92.22 assert round(await interpreter.interprete("min(ma(close, 12)[-1], ema(open, 24)[-1])"), 2) == 90.21 assert round(await interpreter.interprete("mean(close[-1], open[-1], high[-1], low[-1])"), 2) == 92.22 assert round(await interpreter.interprete("round(ma(close, 12)[-1], 2)"), 2) == 92.95 assert round(await interpreter.interprete("floor(close[-1])"), 2) == 92 assert round(await interpreter.interprete("ceil(close[-1])"), 2) == 93 assert round(await interpreter.interprete("abs(close[-1] - open[-1])"), 2) == 0 assert round(await interpreter.interprete("100 if close[-1] > open[-1] else (90 + 1)"), 2) == 91 ================================================ FILE: Meta/DSL_operators/ta_operators/tests/test_tulipy_technical_analysis_operators.py ================================================ # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.errors from tentacles.Meta.DSL_operators.exchange_operators.tests import ( historical_prices, historical_volume, historical_times, exchange_manager_with_candles, interpreter, ) @pytest.mark.asyncio @pytest.mark.parametrize("operator, static_parameters", [ # list all operator and "possible" invalid parameters ("rsi", ["", "(close)", "(close, 14, 20)"]), ("macd", ["", "(close)", "(close, 'a')", "(close, 'a', 26)", "('a', 14, 26, 9, 0)"]), ("ma", ["", "(close)", "(close, 14, 20)"]), ("ema", ["", "(close)", "(close, 14, 20)"]), ("vwma", ["", "(close)", "('a', 14)", "(close, 'a')", "(close, 14, 11, 20)"]), ]) async def test_operator_invalid_static_parameters(interpreter, operator, static_parameters): for param in static_parameters: with pytest.raises(octobot_commons.errors.InvalidParametersError, match=f"{operator} "): # static validation interpreter.prepare(f"{operator}{param}") with pytest.raises(octobot_commons.errors.InvalidParametersError): # dynamic validation await interpreter.interprete(f"{operator}{param}") @pytest.mark.asyncio @pytest.mark.parametrize("operator, dynamic_parameters", [ # list all operator and "possible" invalid parameters ("rsi", ["('a', 14)", "(close, 'a')"]), ("macd", ["('a', 14, 26, 9)", "(close, 'a', 26, 9)", "(close, 14, 'a', 9)", "(close, 14, 26, 'a')"]), ("ma", ["('a', 14)", "(close, 'a')"]), ("ema", ["('a', 14)", "(close, 'a')"]), ("vwma", ["(close, volume, 'a')", "(close, 14, 20)"]), ]) async def test_operator_invalid_dynamic_parameters(interpreter, operator, dynamic_parameters): for param in dynamic_parameters: # static validation: do not raise interpreter.prepare(f"{operator}{param}") with pytest.raises(octobot_commons.errors.InvalidParametersError): # dynamic validation await interpreter.interprete(f"{operator}{param}") @pytest.mark.asyncio @pytest.mark.parametrize("operator, dynamic_parameters", [ # list all operator and invalid parameters that should raise a tulipy error that will be converted to a TypeError ("rsi", ["(close, 999999)", "(close, 0)", "(close, -1)"]), ("macd", ["(close, 14, 99999, 2)", "(close, 99999, 12, 2)", "(close, 0, 12, 2)", "(close, 7, 12, -1)"]), ("ma", ["(close, 999999)", "(close, 0)", "(close, -1)"]), ("ema", ["(close, -1)"]), ("vwma", ["(close, volume, 999999)", "(close, volume, 0)", "(close, volume, -1)"]), ]) async def test_operator_converted_tulipy_error(interpreter, operator, dynamic_parameters): for param in dynamic_parameters: # static validation: do not raise interpreter.prepare(f"{operator}{param}") with pytest.raises(TypeError): # dynamic validation await interpreter.interprete(f"{operator}{param}") @pytest.mark.asyncio async def test_operator_operations(interpreter): # ensure the output is a list and can be used in arithmetic operations assert isinstance(await interpreter.interprete("rsi(close, 14)"), list) assert await interpreter.interprete("round(rsi(close, 26)[-1], 2)") == 74.3 assert await interpreter.interprete("round(rsi(close, 14)[-1], 2)") == 67.55 assert await interpreter.interprete("round(rsi(close, 26)[-1] - rsi(close, 14)[-1], 2)") == 6.74 # combine ma & vwma ma = await interpreter.interprete("ma(close, 14)") vwma = await interpreter.interprete("vwma(close, volume, 14)") assert round(ma[-1], 2) == 92.53 assert round(vwma[-1], 2) == 92.37 assert round(ma[-1]*0.7 + vwma[-1]*0.3, 2) == 92.48 assert await interpreter.interprete("round(ma(close, 14)[-1]*0.7 + vwma(close, volume, 14)[-1]*0.3, 2)") == 92.48 @pytest.mark.asyncio async def test_rsi_operator(interpreter): rsi = await interpreter.interprete("rsi(close, 14)") rounded_rsi = [round(v, 2) for v in rsi] assert rounded_rsi == [ 79.56, 78.6, 77.04, 81.67, 82.88, 84.06, 87.44, 88.03, 85.21, 85.81, 86.73, 78.58, 78.71, 70.4, 72.5, 72.78, 67.78, 67.55 ] # different periods, different result rsi = await interpreter.interprete("rsi(close, 20)") rounded_rsi = [round(v, 2) for v in rsi] assert rounded_rsi == [ 85.71, 86.2, 84.2, 84.66, 85.37, 79.61, 79.7, 73.72, 75.04, 75.22, 71.62, 71.46 ] assert await interpreter.interprete("round(rsi(close, 26)[-1], 2)") == 74.3 assert await interpreter.interprete("round(rsi(close, 14)[-1], 2)") == 67.55 assert await interpreter.interprete("round(rsi(close, 26)[-1] - rsi(close, 14)[-1], 2)") == 6.74 @pytest.mark.asyncio async def test_macd_operator(interpreter): macd = await interpreter.interprete("macd(close, 12, 26, 9)") rounded_macd = [round(v, 2) for v in macd] assert rounded_macd == [0.0, -0.03, -0.14, -0.18, -0.22, -0.29, -0.34] # different parameters, different result macd = await interpreter.interprete("macd(close, 9, 26, 9)") rounded_macd = [round(v, 2) for v in macd] assert rounded_macd == [ 0.0, -0.09, -0.29, -0.36, -0.41, -0.52, -0.59 ] macd = await interpreter.interprete("macd(close, 9, 20, 9)") rounded_macd = [round(v, 2) for v in macd] assert rounded_macd == [ 0.0, 0.26, 0.41, 0.41, 0.38, 0.36, 0.21, 0.07, -0.14, -0.23, -0.29, -0.4, -0.46 ] macd = await interpreter.interprete("macd(close, 9, 20, 6)") rounded_macd = [round(v, 2) for v in macd] assert rounded_macd == [ 0.0, 0.23, 0.35, 0.32, 0.28, 0.25, 0.1, -0.01, -0.19, -0.24, -0.26, -0.33, -0.37 ] @pytest.mark.asyncio async def test_ma_operator(interpreter): ma = await interpreter.interprete("ma(close, 14)") rounded_ma = [round(v, 2) for v in ma] assert rounded_ma == [ 84.12, 84.53, 84.97, 85.26, 85.7, 86.13, 86.64, 87.36, 88.03, 88.63, 89.28, 89.9, 90.37, 90.82, 91.12, 91.52, 91.93, 92.3, 92.53 ] # different periods, different result ma = await interpreter.interprete("ma(close, 20)") rounded_ma = [round(v, 2) for v in ma] assert rounded_ma == [ 85.41, 85.98, 86.59, 87.1, 87.62, 88.15, 88.65, 89.16, 89.57, 89.98, 90.41, 90.75, 91.03 ] @pytest.mark.asyncio async def test_vwma_operator(interpreter): vwma = await interpreter.interprete("vwma(close, volume, 14)") rounded_vwma = [round(v, 2) for v in vwma] assert rounded_vwma == [ # different results from ma(close, 14) 84.15, 84.51, 84.87, 85.29, 85.66, 86.3, 86.76, 87.37, 88.02, 88.55, 89.1, 89.9, 90.31, 90.87, 91.16, 91.53, 91.91, 92.19, 92.37 ] # different periods, different result vwma = await interpreter.interprete("vwma(close, volume, 20)") rounded_vwma = [round(v, 2) for v in vwma] assert rounded_vwma == [ 85.52, 85.93, 86.5, 87.19, 87.66, 88.06, 88.53, 89.24, 89.6, 89.9, 90.27, 90.84, 91.08 ] @pytest.mark.asyncio async def test_ema_operator(interpreter): ema = await interpreter.interprete("ema(close, 14)") rounded_ema = [round(v, 2) for v in ema] assert rounded_ema == [ # different results from ma(close, 14) 81.59, 81.52, 81.7, 81.87, 82.1, 82.24, 82.32, 82.55, 82.81, 83.02, 83.35, 83.78, 84.19, 84.67, 85.02, 85.31, 85.53, 86.0, 86.49, 87.01, 87.78, 88.53, 89.13, 89.7, 90.29, 90.67, 91.0, 91.15, 91.37, 91.58, 91.67, 91.74 ] # different periods, different result ema = await interpreter.interprete("ema(close, 20)") rounded_ema = [round(v, 2) for v in ema] assert rounded_ema == [ 81.59, 81.54, 81.67, 81.79, 81.97, 82.08, 82.15, 82.33, 82.54, 82.71, 82.98, 83.32, 83.66, 84.05, 84.36, 84.63, 84.85, 85.25, 85.67, 86.12, 86.76, 87.39, 87.92, 88.45, 88.99, 89.38, 89.75, 89.97, 90.24, 90.5, 90.66, 90.81 ] ================================================ FILE: Meta/DSL_operators/ta_operators/tulipy_technical_analysis_operators.py ================================================ # pylint: disable=missing-class-docstring,missing-function-docstring # Drakkar-Software OctoBot-Commons # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tulipy import numpy as np import octobot_commons.errors import tentacles.Meta.DSL_operators.ta_operators.ta_operator as ta_operator import octobot_commons.dsl_interpreter as dsl_interpreter def _to_numpy_array(data): if isinstance(data, list): return np.array(data, dtype=np.float64) elif isinstance(data, tuple): return np.array(list(data), dtype=np.float64) elif isinstance(data, np.ndarray): if data.dtype != np.float64: return data.astype(np.float64) return data else: raise octobot_commons.errors.InvalidParametersError(f"Unsupported data type: {type(data)}") def _to_int(value): if isinstance(value, int): return value elif isinstance(value, float): return int(value) else: raise octobot_commons.errors.InvalidParametersError(f"Unsupported value type: {type(value)}") def converted_tulipy_error(f): def converted_tulipy_error_wrapper(*args, **kwargs): try: return f(*args, **kwargs) except tulipy.InvalidOptionError as err: raise TypeError( f"Invalid technical indicator parameter - {err.__class__.__name__}" ) from err return converted_tulipy_error_wrapper class RSIOperator(ta_operator.TAOperator): DESCRIPTION = "Returns the Relative Strength Index (RSI) of the given array of numbers" EXAMPLE = "rsi([100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110], 14)" @staticmethod def get_name() -> str: return "rsi" @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="data", description="the data to compute the RSI on", required=True, type=list), dsl_interpreter.OperatorParameter(name="period", description="the period to use for the RSI", required=True, type=int), ] @converted_tulipy_error def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() return list(tulipy.rsi(_to_numpy_array(operands[0]), period=_to_int(operands[1]))) class MACDOperator(ta_operator.TAOperator): DESCRIPTION = "Returns the Moving Average Convergence Divergence (MACD) of the given array of numbers" EXAMPLE = "macd(close('BTC/USDT', '1h'), 12, 26, 9)" @staticmethod def get_name() -> str: return "macd" @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="data", description="the data to compute the MACD on", required=True, type=list), dsl_interpreter.OperatorParameter(name="short_period", description="the short period to use for the MACD", required=True, type=int), dsl_interpreter.OperatorParameter(name="long_period", description="the long period to use for the MACD", required=True, type=int), dsl_interpreter.OperatorParameter(name="signal_period", description="the signal period to use for the MACD", required=True, type=int), ] @converted_tulipy_error def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() macd, macd_signal, macd_hist = tulipy.macd( _to_numpy_array(operands[0]), short_period=_to_int(operands[1]), long_period=_to_int(operands[2]), signal_period=_to_int(operands[3]) ) return list(macd_hist) class MAOperator(ta_operator.TAOperator): DESCRIPTION = "Returns the moving average of the given array of numbers" EXAMPLE = "ma(close('BTC/USDT', '1h'), 14)" @staticmethod def get_name() -> str: return "ma" @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="data", description="the data to compute the moving average on", required=True, type=list), dsl_interpreter.OperatorParameter(name="period", description="the period to use for the moving average", required=True, type=int), ] @converted_tulipy_error def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() return list(tulipy.sma(_to_numpy_array(operands[0]), period=_to_int(operands[1]))) class EMAOperator(ta_operator.TAOperator): DESCRIPTION = "Returns the exponential moving average of the given array of numbers" EXAMPLE = "ema(close('BTC/USDT', '1h'), 14)" @staticmethod def get_name() -> str: return "ema" @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="data", description="the data to compute the exponential moving average on", required=True, type=list), dsl_interpreter.OperatorParameter(name="period", description="the period to use for the exponential moving average", required=True, type=int), ] @converted_tulipy_error def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() return list(tulipy.ema(_to_numpy_array(operands[0]), period=_to_int(operands[1]))) class VWMAOperator(ta_operator.TAOperator): DESCRIPTION = "Returns the volume weighted moving average of the given array of numbers" EXAMPLE = "vwma(close('BTC/USDT', '1h'), volume('BTC/USDT', '1h'), 14)" @staticmethod def get_name() -> str: return "vwma" @staticmethod def get_parameters() -> list[dsl_interpreter.OperatorParameter]: return [ dsl_interpreter.OperatorParameter(name="data", description="the data to compute the volume weighted moving average on", required=True, type=list), dsl_interpreter.OperatorParameter(name="volume", description="the volume data to use for the volume weighted moving average", required=True, type=list), dsl_interpreter.OperatorParameter(name="period", description="the period to use for the volume weighted moving average", required=True, type=int), ] @converted_tulipy_error def compute(self) -> dsl_interpreter.ComputedOperatorParameterType: operands = self.get_computed_parameters() return list(tulipy.vwma(_to_numpy_array(operands[0]), _to_numpy_array(operands[1]), period=_to_int(operands[2]))) ================================================ FILE: Meta/Keywords/scripting_library/TA/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .trigger import * ================================================ FILE: Meta/Keywords/scripting_library/TA/trigger/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .eval_triggered import * ================================================ FILE: Meta/Keywords/scripting_library/TA/trigger/eval_triggered.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as commons_constants import octobot_commons.errors as commons_errors import octobot_commons.enums as commons_enums import octobot_commons.dict_util as dict_util import octobot_evaluators.matrix as matrix import octobot_evaluators.enums as evaluators_enums import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.modes.script_keywords as script_keywords import tentacles.Meta.Keywords.scripting_library.UI.inputs.triggers as triggers # 10000000000 = Sat, 20 Nov 2286 17:46:40 GMT to select all values ALL_VALUES_CACHE_KEY = 10000000000.0 def _is_first_candle_only(context): if not context.exchange_manager.is_backtesting: # this is a backtesting only optimization return False tentacle_config = context.tentacle.get_local_config() return tentacle_config.get(triggers.TRIGGER_ONLY_ON_THE_FIRST_CANDLE_KEY, False) def _is_first_candle_call(context, init_key): # TODO: figure out if we currently are in the 1st call of the given candle (careful with timeframes) return not context.symbol_writer.are_data_initialized_by_key.get(init_key, False) async def evaluator_get_result( context: script_keywords.Context, tentacle_class, time_frame=None, symbol: str = None, trigger: bool = False, value_key=commons_enums.CacheDatabaseColumns.VALUE.value, cache_key=None, config_name: str = None, config: dict = None ): tentacle_class = tentacles_manager_api.get_tentacle_class_from_string(tentacle_class) \ if isinstance(tentacle_class, str) else tentacle_class config_name = context.get_config_name_or_default(tentacle_class, config_name) init_key = _get_init_key(context, config_name) is_first_candle_only = _is_first_candle_only(context) should_trigger = not is_first_candle_only or (is_first_candle_only and _is_first_candle_call(context, init_key)) if not context.symbol_writer.are_data_initialized_by_key.get(init_key, False) or (should_trigger and trigger): with context.adapted_trigger_timestamp(tentacle_class, config_name): # always trigger when asked to then return the triggered evaluation return return (await _trigger_single_evaluation(context, tentacle_class, value_key, cache_key, config_name, config, init_key))[0] if tentacle_class.use_cache(): # try reading from cache try: with context.adapted_trigger_timestamp(tentacle_class, config_name): await context.ensure_tentacle_cache_requirements(tentacle_class, config_name) value, is_missing = await context.get_cached_value(value_key=value_key, cache_key=cache_key, tentacle_name=tentacle_class.__name__, config_name=config_name) if not is_missing: return value except commons_errors.UninitializedCache as e: if tentacle_class is not None and trigger is False: raise commons_errors.UninitializedCache(f"Can't read cache from {tentacle_class} before initializing " f"it. Either activate this tentacle or set the 'trigger' " f"parameter to True (error: {e})") from None _ensure_cache_when_set_value_key(value_key, tentacle_class) # read from evaluation matrix for value in _tentacle_values(context, tentacle_class, time_frame=time_frame, symbol=symbol): return value async def evaluator_get_results( context: script_keywords.Context, tentacle_class, time_frame=None, symbol: str = None, trigger: bool = False, value_key=commons_enums.CacheDatabaseColumns.VALUE.value, cache_key=None, limit: int = -1, max_history: bool = False, config_name: str = None, config: dict = None ): cache_key = ALL_VALUES_CACHE_KEY if max_history else cache_key tentacle_class = tentacles_manager_api.get_tentacle_class_from_string(tentacle_class) \ if isinstance(tentacle_class, str) else tentacle_class config_name = context.get_config_name_or_default(tentacle_class, config_name) init_key = _get_init_key(context, config_name) is_first_candle_only = _is_first_candle_only(context) should_trigger = not is_first_candle_only or (is_first_candle_only and _is_first_candle_call(context, init_key)) if not context.symbol_writer.are_data_initialized_by_key.get(init_key, False) or (should_trigger and trigger): with context.adapted_trigger_timestamp(tentacle_class, config_name): # always trigger when asked to eval_result, _ = await _trigger_single_evaluation(context, tentacle_class, value_key, cache_key, config_name, config, init_key) if limit == 1: # return already if only one value to return return eval_result if tentacle_class.use_cache(): try: with context.adapted_trigger_timestamp(tentacle_class, config_name): await context.ensure_tentacle_cache_requirements(tentacle_class, config_name) # can return multiple values return await context.get_cached_values(value_key=value_key, cache_key=cache_key, limit=limit, tentacle_name=tentacle_class.__name__, config_name=config_name) except commons_errors.UninitializedCache: if tentacle_class is not None and trigger is False: raise commons_errors.UninitializedCache(f"Can't read cache from {tentacle_class} before initializing " f"it. Either activate this tentacle or set the 'trigger' " f"parameter to True") from None _ensure_cache_when_set_value_key(value_key, tentacle_class) if limit == 1: # read from evaluation matrix for value in _tentacle_values(context, tentacle_class, time_frame=time_frame, symbol=symbol): return value raise commons_errors.MissingDataError(f"No evaluator value for {tentacle_class.__name__}") else: raise commons_errors.ConfigEvaluatorError(f"Evaluator cache is required to get more than one historical value " f"of an evaluator. Cache is disabled on {tentacle_class.__name__}") def _ensure_cache_when_set_value_key(value_key, tentacle_class): if not tentacle_class.use_cache() and value_key != commons_enums.CacheDatabaseColumns.VALUE.value: raise commons_errors.ConfigEvaluatorError(f"Evaluator cache is required to read a value_key different from " f"the evaluator output evaluation. " f"Cache is disabled on {tentacle_class.__name__}") async def _trigger_single_evaluation(context, tentacle_class, value_key, cache_key, config_name, config, init_key): config_name, cleaned_config_name, config, tentacles_setup_config, tentacle_config = \ context.get_tentacle_config_elements(tentacle_class, config_name, config) async with context.local_nested_tentacle_config(tentacle_class, config_name, True): is_eval_result_set = False eval_result = evaluator_instance = None if cleaned_config_name not in tentacle_config or \ not context.symbol_writer.are_data_initialized_by_key.get(init_key, False): # always call _init_nested_call the 1st time the evaluation chain is triggered to make sure scripts # are executed entirely at least once # might need to merge config with tentacles_manager_api.get_tentacle_config if evaluator is # not filling default config values init_config = {**tentacle_config.get(cleaned_config_name, {}), **config} eval_result, error, evaluator_instance = await _init_nested_call( context, tentacle_class, config_name, cleaned_config_name, tentacles_setup_config, tentacle_config, init_config ) if error is None: is_eval_result_set = True try: tentacle_config = tentacle_config[cleaned_config_name] except KeyError as e: raise commons_errors.ConfigEvaluatorError(f"Missing evaluator configuration with name {e}") # apply forced config if any dict_util.nested_update_dict(tentacle_config, config) await script_keywords.save_user_input( context, config_name, commons_constants.NESTED_TENTACLE_CONFIG, tentacle_config, {}, is_nested_config=context.nested_depth > 1, nested_tentacle=tentacle_class.get_name() ) if not is_eval_result_set: eval_result, _, evaluator_instance = (await tentacle_class.single_evaluation( tentacles_setup_config, tentacle_config, context=context )) if value_key == commons_enums.CacheDatabaseColumns.VALUE.value and cache_key is None: return eval_result, evaluator_instance.specific_config else: value, is_missing = await context.get_cached_value(value_key=value_key, cache_key=cache_key, tentacle_name=tentacle_class.__name__, config_name=config_name, ignore_requirement=True) return None if is_missing else value, evaluator_instance.specific_config async def _init_nested_call(context, tentacle_class, config_name, cleaned_config_name, tentacles_setup_config, tentacle_config, config): evaluation, error, evaluator_instance = await tentacle_class.single_evaluation( tentacles_setup_config, config, context=context, ignore_cache=True ) tentacle_config[cleaned_config_name] = evaluator_instance.specific_config if error is not None: _invalidate_call_and_parents_init_status(context, config_name) else: context.symbol_writer.are_data_initialized_by_key[_get_init_key(context, config_name)] = True return evaluation, error, evaluator_instance def _get_init_key(context, config_name): return f"{config_name}_{context.time_frame}" def _invalidate_call_and_parents_init_status(context, config_name): # set are_data_initialized_by_key to False for this evaluator and its parent calls to ensure init is called # again later and the evaluator can be run entirely context.symbol_writer.are_data_initialized_by_key[_get_init_key(context, config_name)] = False for nested_config_name in context.nested_config_names: context.symbol_writer.are_data_initialized_by_key[_get_init_key(context, nested_config_name)] = False def _tentacle_values(context, tentacle_class, time_frames=None, symbols=None, time_frame=None, symbol=None): tentacle_name = tentacle_class if isinstance(tentacle_class, str) else tentacle_class.get_name() symbols = [context.symbol or symbol] or symbols time_frames = [context.time_frame or time_frame] or time_frames for symbol in symbols: for time_frame in time_frames: for tentacle_type in evaluators_enums.EvaluatorMatrixTypes: for evaluated_ta_node in matrix.get_tentacles_value_nodes( context.matrix_id, matrix.get_tentacle_nodes(context.matrix_id, exchange_name=context.exchange_name, tentacle_type=tentacle_type.value, tentacle_name=tentacle_name), symbol=symbol, time_frame=time_frame): yield evaluated_ta_node.node_value ================================================ FILE: Meta/Keywords/scripting_library/UI/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .inputs import * from .plots import * ================================================ FILE: Meta/Keywords/scripting_library/UI/inputs/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .library_user_inputs import * from .select_time_frame import * from .select_candle import * from .select_history import * from .triggers import * ================================================ FILE: Meta/Keywords/scripting_library/UI/inputs/library_user_inputs.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.configuration as commons_configuration import tentacles.Meta.Keywords.scripting_library.TA.trigger.eval_triggered as eval_triggered import octobot_tentacles_manager.api as tentacles_manager_api def _find_configuration(nested_configuration, nested_config_names, element): for key, config in nested_configuration.items(): if len(nested_config_names) == 0 and key == element: return config if isinstance(config, dict) and (len(nested_config_names) == 0 or key == nested_config_names[0]): found_config = _find_configuration(config, nested_config_names[1:], element) if found_config is not None: return found_config return None async def external_user_input( ctx, name, tentacle, config_name=None, trigger_if_necessary=True, include_tentacle_as_requirement=True, config: dict = None ): triggered = False try: if config_name is None: query = await ctx.run_data_writer.search() raw_value = await ctx.run_data_writer.select( commons_enums.DBTables.INPUTS.value, (query.name == name) & (query.tentacle == tentacle) ) if raw_value: return raw_value[0]["value"] else: # look for the user input in non nested user inputs user_inputs = await commons_configuration.get_user_inputs(ctx.run_data_writer) # First try with the current top level tentacle (faster and to avoid name conflicts) top_tentacle_config = ctx.top_level_tentacle.get_local_config() tentacle_config = _find_configuration(top_tentacle_config, ctx.nested_config_names, config_name.replace(" ", "_")) if tentacle_config is None: # Then try with the current local tentacle, then use all tentacles current_tentacle_config = ctx.tentacle.get_local_config() tentacle_config = current_tentacle_config.get(config_name.replace(" ", "_"), None) if tentacle_config is None: for local_user_input in user_inputs: if not local_user_input["is_nested_config"] and \ local_user_input["input_type"] == commons_constants.NESTED_TENTACLE_CONFIG: tentacle_config = _find_configuration(local_user_input["value"], ctx.nested_config_names, config_name.replace(" ", "_")) if tentacle_config is not None: break if not trigger_if_necessary: # look into nested config as well since the tentacle wont be triggered for local_user_input in user_inputs: if local_user_input["is_nested_config"] and \ local_user_input["input_type"] == commons_constants.NESTED_TENTACLE_CONFIG: if local_user_input["name"] == config_name: tentacle_config = local_user_input["value"] break tentacle_config = _find_configuration(local_user_input["value"], ctx.nested_config_names, config_name.replace(" ", "_")) if tentacle_config is not None: break if tentacle_config is None and trigger_if_necessary: tentacle_class = tentacles_manager_api.get_tentacle_class_from_string(tentacle) \ if isinstance(tentacle, str) else tentacle _, tentacle_config = await eval_triggered._trigger_single_evaluation( ctx, tentacle_class, commons_enums.CacheDatabaseColumns.VALUE.value, None, config_name, config) triggered = True try: return None if tentacle_config is None else tentacle_config[name.replace(" ", "_")] except KeyError: return None finally: if include_tentacle_as_requirement and not triggered and trigger_if_necessary: # to register the tentacle as requirement: trigger its evaluation in a nested context tentacle_class = tentacles_manager_api.get_tentacle_class_from_string(tentacle) \ if isinstance(tentacle, str) else tentacle await eval_triggered._trigger_single_evaluation( ctx, tentacle_class, commons_enums.CacheDatabaseColumns.VALUE.value, None, config_name, config) return None ================================================ FILE: Meta/Keywords/scripting_library/UI/inputs/select_candle.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_public_data as exchange_public_data async def user_select_candle( ctx, name="Select Candle Source", def_val="close", time_frame=None, symbol=None, limit=-1, enable_volume=True, return_source_name=False, max_history=False, show_in_summary=True, show_in_optimizer=True, order=None, ): available_data_src = ["open", "high", "low", "close", "hl2", "hlc3", "ohlc4", "Heikin Ashi open", "Heikin Ashi high", "Heikin Ashi low", "Heikin Ashi close"] if enable_volume: available_data_src.append("volume") data_source = await basic_keywords.user_input(ctx, name, "options", def_val, options=available_data_src, show_in_summary=show_in_summary, show_in_optimizer=show_in_optimizer, order=order) candle_source = await exchange_public_data.get_candles_from_name( ctx, source_name=data_source, time_frame=time_frame, symbol=symbol, limit=limit, max_history=max_history ) if return_source_name: return candle_source, data_source else: return candle_source ================================================ FILE: Meta/Keywords/scripting_library/UI/inputs/select_history.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords import octobot_trading.constants as trading_constants async def set_candles_history_size( ctx, def_val=trading_constants.DEFAULT_CANDLE_HISTORY_SIZE, name=trading_constants.CONFIG_CANDLES_HISTORY_SIZE_TITLE, show_in_summary=False, show_in_optimizer=False, order=999, ): return await basic_keywords.user_input(ctx, name, "int", def_val, show_in_summary=show_in_summary, show_in_optimizer=show_in_optimizer, order=order) ================================================ FILE: Meta/Keywords/scripting_library/UI/inputs/select_time_frame.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.constants as commons_constants import octobot_commons.errors as commons_errors import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords import octobot_evaluators.evaluators as evaluators import octobot_evaluators.matrix as matrix async def user_select_time_frame( ctx, def_val="1h", name="Timeframe", show_in_summary=True, show_in_optimizer=True, order=None ): available_timeframes = time_frame_manager.sort_time_frames(ctx.exchange_manager.client_time_frames) selected_timeframe = await basic_keywords.user_input(ctx, name, "options", def_val, options=available_timeframes, show_in_summary=show_in_summary, show_in_optimizer=show_in_optimizer, order=order) return selected_timeframe async def user_multi_select_time_frame( ctx, def_val="1h", name="Timeframe", show_in_summary=True, show_in_optimizer=True, order=None ): available_timeframes = time_frame_manager.sort_time_frames(ctx.exchange_manager.client_time_frames) selected_timeframe = await basic_keywords.user_input(ctx, name, "multiple-options", def_val, options=available_timeframes, show_in_summary=show_in_summary, show_in_optimizer=show_in_optimizer, order=order) return selected_timeframe async def set_trigger_time_frames( ctx, def_val=None, show_in_summary=True, show_in_optimizer=False, order=None ): available_timeframes = [ tf.value for tf in time_frame_manager.sort_time_frames( ctx.exchange_manager.exchange_config.get_relevant_time_frames() ) ] def_val = def_val or available_timeframes[0] name = commons_constants.CONFIG_TRIGGER_TIMEFRAMES.replace("_", " ") trigger_timeframes = await basic_keywords.user_input(ctx, name, "multiple-options", def_val, options=available_timeframes, show_in_summary=show_in_summary, show_in_optimizer=show_in_optimizer, flush_if_necessary=True, order=order) if ctx.time_frame not in trigger_timeframes: if isinstance(ctx.tentacle, evaluators.AbstractEvaluator): # For evaluators, make sure that undesired time frames are not in matrix anymore. # Otherwise a strategy might wait for their value before pushing its evaluation to trading modes matrix.delete_tentacle_node( matrix_id=ctx.tentacle.matrix_id, tentacle_path=matrix.get_matrix_default_value_path( exchange_name=ctx.exchange_manager.exchange_name, tentacle_type=ctx.tentacle.evaluator_type.value, tentacle_name=ctx.tentacle.get_name(), cryptocurrency=ctx.cryptocurrency, symbol=ctx.symbol, time_frame=ctx.time_frame if ctx.time_frame else None ) ) raise commons_errors.ExecutionAborted(f"Execution aborted: disallowed time frame: {ctx.time_frame}") return trigger_timeframes ================================================ FILE: Meta/Keywords/scripting_library/UI/inputs/triggers.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords TRIGGER_ONLY_ON_THE_FIRST_CANDLE_KEY = "trigger_only_on_the_first_candle" async def trigger_only_on_the_first_candle(ctx, default_value, show_in_summary=False, show_in_optimizer=False, order=700): return await basic_keywords.user_input(ctx, TRIGGER_ONLY_ON_THE_FIRST_CANDLE_KEY, "boolean", default_value, show_in_summary=show_in_summary, show_in_optimizer=show_in_optimizer, order=order) ================================================ FILE: Meta/Keywords/scripting_library/UI/plots/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .displayed_elements import DisplayedElements ================================================ FILE: Meta/Keywords/scripting_library/UI/plots/displayed_elements.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_commons.enums as commons_enums import octobot_commons.errors as commons_errors import octobot_commons.constants as commons_constants import octobot_commons.databases as databases import octobot_commons.display as display import octobot_backtesting.api as backtesting_api import octobot_trading.api as trading_api class DisplayedElements(display.DisplayTranslator): TABLE_KEY_TO_COLUMN = { commons_enums.PlotAttributes.X.value: "Time", commons_enums.PlotAttributes.Y.value: "Value", commons_enums.PlotAttributes.Z.value: "Value", commons_enums.PlotAttributes.OPEN.value: "Open", commons_enums.PlotAttributes.HIGH.value: "High", commons_enums.PlotAttributes.LOW.value: "Low", commons_enums.PlotAttributes.CLOSE.value: "Close", commons_enums.PlotAttributes.VOLUME.value: "Volume", commons_enums.DBRows.SYMBOL.value: "Symbol", } async def fill_from_database(self, trading_mode, database_manager, exchange_name, symbol, time_frame, exchange_id, with_inputs=True, symbols=None, time_frames=None): async with databases.MetaDatabase.database(database_manager) as meta_db: graphs_by_parts = {} inputs = [] candles = [] cached_values = [] if trading_mode.is_backtestable(): exchange_name, symbol, time_frame = \ await self._adapt_inputs_for_backtesting_results(meta_db, exchange_name, symbol, time_frame) run_db = meta_db.get_run_db() metadata_rows = await run_db.all(commons_enums.DBTables.METADATA.value) metadata = metadata_rows[0] if metadata_rows else None if symbols is not None: symbols.extend(metadata[commons_enums.BacktestingMetadata.SYMBOLS.value]) if time_frames is not None: time_frames.extend(metadata[commons_enums.BacktestingMetadata.TIME_FRAMES.value]) account_type = trading_api.get_account_type_from_run_metadata(metadata) \ if database_manager.is_backtesting() \ else trading_api.get_account_type_from_exchange_manager( trading_api.get_exchange_manager_from_exchange_id(exchange_id) ) dbs = [ run_db, meta_db.get_transactions_db(account_type, exchange_name), meta_db.get_orders_db(account_type, exchange_name), meta_db.get_trades_db(account_type, exchange_name), meta_db.get_symbol_db(exchange_name, symbol) ] for index, db in enumerate(dbs): for table_name in await db.tables(): display_data = await db.all(table_name) if table_name == commons_enums.DBTables.INPUTS.value: inputs += display_data if table_name == commons_enums.DBTables.CANDLES_SOURCE.value: candles += display_data if table_name == commons_enums.DBTables.CACHE_SOURCE.value: cached_values += display_data else: try: filter_symbol = index != len(dbs) - 1 # don't filter symbol for symbol db filtered_data = self._filter_and_adapt_displayed_elements( display_data, symbol, time_frame, table_name, filter_symbol ) chart = display_data[0][commons_enums.DisplayedElementTypes.CHART.value] if chart is None: continue if chart in graphs_by_parts: graphs_by_parts[chart][table_name] = filtered_data else: graphs_by_parts[chart] = {table_name: filtered_data} except (IndexError, KeyError): # some table have no chart pass try: run_start_time, run_end_time = await self._get_run_window(meta_db.get_run_db()) except IndexError: run_start_time = run_end_time = 0 first_candle_time, last_candle_time = \ await self._add_candles(graphs_by_parts, candles, exchange_name, exchange_id, symbol, time_frame, run_start_time, run_end_time) await self._add_cached_values(graphs_by_parts, cached_values, time_frame, first_candle_time, last_candle_time) self._plot_graphs(graphs_by_parts) if with_inputs: with self.part(commons_enums.DBTables.INPUTS.value, element_type=commons_enums.DisplayedElementTypes.INPUT.value) as part: self.add_user_inputs(inputs, part) async def _adapt_inputs_for_backtesting_results(self, meta_db, exchange_name, symbol, time_frame): if not await meta_db.run_dbs_identifier.exchange_base_identifier_exists(exchange_name): single_exchange = await meta_db.run_dbs_identifier.get_single_existing_exchange() if single_exchange is None: # no single exchange with data raise commons_errors.MissingExchangeDataError( f"No data for {exchange_name}. This run might have happened on other exchange(s)" ) else: # retarget exchange_name exchange_name = single_exchange if not await meta_db.run_dbs_identifier.symbol_base_identifier_exists(exchange_name, symbol): run_metadata = await meta_db.get_run_db().all(commons_enums.DBTables.METADATA.value) try: symbols = run_metadata[0].get(commons_enums.DBRows.SYMBOLS.value, []) if len(symbols) > 0: # retarget symbol symbol = symbols[0] else: # no single exchange with data raise commons_errors.MissingExchangeDataError( f"No symbol related data for {exchange_name}" ) except IndexError: # no run metadata, try to continue pass return exchange_name, symbol, time_frame def _plot_graphs(self, graphs_by_parts): for part, datasets in graphs_by_parts.items(): with self.part(part, element_type=commons_enums.DisplayedElementTypes.CHART.value) as part: for title, dataset in datasets.items(): if not dataset: continue x = [] y = [] open = [] high = [] low = [] close = [] volume = [] text = [] color = [] size = [] shape = [] if dataset[0].get(commons_enums.PlotAttributes.X.value, None) is None: x = None if dataset[0].get(commons_enums.PlotAttributes.Y.value, None) is None: y = None if dataset[0].get(commons_enums.PlotAttributes.OPEN.value, None) is None: open = None if dataset[0].get(commons_enums.PlotAttributes.HIGH.value, None) is None: high = None if dataset[0].get(commons_enums.PlotAttributes.LOW.value, None) is None: low = None if dataset[0].get(commons_enums.PlotAttributes.CLOSE.value, None) is None: close = None if dataset[0].get(commons_enums.PlotAttributes.VOLUME.value, None) is None: volume = None if dataset[0].get(commons_enums.PlotAttributes.TEXT.value, None) is None: text = None if dataset[0].get(commons_enums.PlotAttributes.COLOR.value, None) is None: color = None if dataset[0].get(commons_enums.PlotAttributes.SIZE.value, None) is None: size = None if dataset[0].get(commons_enums.PlotAttributes.SHAPE.value, None) is None: shape = None own_yaxis = dataset[0].get(commons_enums.PlotAttributes.OWN_YAXIS.value, False) for data in dataset: if x is not None: x.append(data[commons_enums.PlotAttributes.X.value]) if y is not None: y.append(data[commons_enums.PlotAttributes.Y.value]) if open is not None: open.append(data[commons_enums.PlotAttributes.OPEN.value]) if high is not None: high.append(data[commons_enums.PlotAttributes.HIGH.value]) if low is not None: low.append(data[commons_enums.PlotAttributes.LOW.value]) if close is not None: close.append(data[commons_enums.PlotAttributes.CLOSE.value]) if volume is not None: volume.append(data[commons_enums.PlotAttributes.VOLUME.value]) if text is not None: text.append(data[commons_enums.PlotAttributes.TEXT.value]) if color is not None: color.append(data[commons_enums.PlotAttributes.COLOR.value]) if size is not None: size.append(data[commons_enums.PlotAttributes.SIZE.value]) if shape is not None: shape.append(data[commons_enums.PlotAttributes.SHAPE.value]) # use log scale for all positive charts y_type = None if title == commons_enums.DBTables.CANDLES_SOURCE.value \ or 0 <= min(d.get(commons_enums.PlotAttributes.Y.value, 0) for d in dataset): y_type = "log" part.plot( kind=data.get(commons_enums.PlotAttributes.KIND.value, None), x=x, y=y, open=open, high=high, low=low, close=close, volume=volume, title=title, text=text, x_type="date", y_type=y_type, mode=data.get(commons_enums.PlotAttributes.MODE.value, None), own_yaxis=own_yaxis, color=color, size=size, symbol=shape) def _adapt_for_display(self, table_name, filtered_elements): if table_name == commons_enums.DBTables.TRANSACTIONS.value: # only display liquidations filtered_elements = [ display_element for display_element in filtered_elements if display_element.get("trigger_source", None) == trading_enums.PNLTransactionSource.LIQUIDATION.value ] for display_element in filtered_elements: display_element[commons_enums.PlotAttributes.COLOR.value] = "red" display_element[commons_enums.PlotAttributes.SHAPE.value] = commons_enums.PlotAttributes.X.value display_element[commons_enums.PlotAttributes.SIZE.value] = 15 display_element[commons_enums.PlotAttributes.TEXT.value] = f"Liquidation ({abs(display_element.get('closed_quantity', 0))} liquidated)" display_element[commons_enums.PlotAttributes.Y.value] = display_element["order_exit_price"] elif table_name == commons_enums.DBTables.ORDERS.value: # adapt order details for display for display_element in filtered_elements: order_details = display_element[trading_constants.STORAGE_ORIGIN_VALUE] side = order_details[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] display_element[commons_enums.PlotAttributes.COLOR.value] = "red" \ if side == trading_enums.TradeOrderSide.SELL.value else "green" display_element[commons_enums.PlotAttributes.SHAPE.value] = "line-ew-open" display_element[commons_enums.PlotAttributes.MODE.value] = "markers" display_element[commons_enums.PlotAttributes.KIND.value] = "scattergl" display_element[commons_enums.PlotAttributes.SIZE.value] = 15 display_element[commons_enums.PlotAttributes.TEXT.value] = \ f"{order_details[trading_enums.ExchangeConstantsOrderColumns.TYPE.value]} " \ f"{order_details[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value]} " \ f"{order_details[trading_enums.ExchangeConstantsOrderColumns.QUANTITY_CURRENCY.value]} " \ f"at {order_details[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]}" display_element[commons_enums.PlotAttributes.Y.value] = \ order_details[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] display_element[commons_enums.PlotAttributes.X.value] = \ order_details[trading_enums.ExchangeConstantsOrderColumns.TIMESTAMP.value] * 1000 display_element[commons_enums.DisplayedElementTypes.CHART.value] = \ commons_enums.PlotCharts.MAIN_CHART.value return filtered_elements def _filter_and_adapt_displayed_elements(self, elements, symbol, time_frame, table_name, filter_symbol): default_symbol = None if filter_symbol else symbol filtered_elements = [ display_element for display_element in elements if ( display_element.get(commons_enums.DBRows.SYMBOL.value, default_symbol) == symbol and display_element.get(commons_enums.DBRows.TIME_FRAME.value) == time_frame ) or ( display_element.get(trading_constants.STORAGE_ORIGIN_VALUE, {}) .get(trading_enums.ExchangeConstantsOrderColumns.SYMBOL.value, default_symbol) == symbol ) ] return self._adapt_for_display(table_name, filtered_elements) async def _get_run_window(self, run_database): run_metadata = (await run_database.all(commons_enums.DBTables.METADATA.value))[0] end_time = run_metadata.get("end_time", 0) if end_time == -1: # live mode return 0, 0 return run_metadata.get("start_time", 0), end_time async def _add_cached_values(self, graphs_by_parts, cached_values, time_frame, start_time, end_time): start_time = start_time end_time = end_time for cached_value_metadata in cached_values: if cached_value_metadata.get(commons_enums.DBRows.TIME_FRAME.value, None) == time_frame: try: chart = cached_value_metadata[commons_enums.DisplayedElementTypes.CHART.value] x_shift = cached_value_metadata["x_shift"] values = sorted(await self._get_cached_values_to_display(cached_value_metadata, x_shift, start_time, end_time), key=lambda x: x[commons_enums.PlotAttributes.X.value]) try: graphs_by_parts[chart][cached_value_metadata[commons_enums.PlotAttributes.TITLE.value]] = values except KeyError: if chart not in graphs_by_parts: graphs_by_parts[chart] = {} try: graphs_by_parts[chart] = \ {cached_value_metadata[commons_enums.PlotAttributes.TITLE.value]: values} except KeyError: graphs_by_parts[chart] = {commons_enums.PlotAttributes.TITLE.value: values} except KeyError: # some table have no chart pass async def _get_cached_values_to_display(self, cached_value_metadata, x_shift, start_time, end_time): cache_file = cached_value_metadata[commons_enums.PlotAttributes.VALUE.value] cache_displayed_value = plotted_displayed_value = cached_value_metadata["cache_value"] kind = cached_value_metadata[commons_enums.PlotAttributes.KIND.value] mode = cached_value_metadata[commons_enums.PlotAttributes.MODE.value] own_yaxis = cached_value_metadata[commons_enums.PlotAttributes.OWN_YAXIS.value] condition = cached_value_metadata.get("condition", None) try: cache_database = databases.CacheDatabase(cache_file) cache_type = (await cache_database.get_metadata())[commons_enums.CacheDatabaseColumns.TYPE.value] if cache_type == databases.CacheTimestampDatabase.__name__: cache = await cache_database.get_cache() for cache_val in cache: try: if isinstance(cache_val[cache_displayed_value], bool): plotted_displayed_value = self._get_cache_displayed_value(cache_val, cache_displayed_value) if plotted_displayed_value is None: self.logger.error(f"Impossible to plot {cache_displayed_value}: unset y axis value") return [] else: break except KeyError: pass except Exception as e: print(e) plotted_values = [] for values in cache: try: if condition is None or condition == values[cache_displayed_value]: x = (values[commons_enums.CacheDatabaseColumns.TIMESTAMP.value] + x_shift) * 1000 if (start_time == end_time == 0) or start_time <= x <= end_time: y = values[plotted_displayed_value] if not isinstance(x, list) and isinstance(y, list): for y_val in y: plotted_values.append({ commons_enums.PlotAttributes.X.value: x, commons_enums.PlotAttributes.Y.value: y_val, commons_enums.PlotAttributes.KIND.value: kind, commons_enums.PlotAttributes.MODE.value: mode, commons_enums.PlotAttributes.OWN_YAXIS.value: own_yaxis, }) else: plotted_values.append({ commons_enums.PlotAttributes.X.value: x, commons_enums.PlotAttributes.Y.value: y, commons_enums.PlotAttributes.KIND.value: kind, commons_enums.PlotAttributes.MODE.value: mode, commons_enums.PlotAttributes.OWN_YAXIS.value: own_yaxis, }) except KeyError: pass return plotted_values self.logger.error(f"Unhandled cache type to display: {cache_type}") except TypeError: self.logger.error(f"Missing cache type in {cache_file} metadata file") except commons_errors.DatabaseNotFoundError as ex: self.logger.warning(f"Missing cache values ({ex})") return [] @staticmethod def _get_cache_displayed_value(cache_val, base_displayed_value): for key in cache_val.keys(): separator_split_key = key.split(commons_constants.CACHE_RELATED_DATA_SEPARATOR) if base_displayed_value == separator_split_key[0] and len(separator_split_key) == 2: return key return None async def _add_candles(self, graphs_by_parts, candles_list, exchange_name, exchange_id, symbol, time_frame, run_start_time, run_end_time): first_candle_time = last_candle_time = 0 for candles_metadata in candles_list: if candles_metadata.get(commons_enums.DBRows.TIME_FRAME.value) == time_frame: try: chart = candles_metadata[commons_enums.DisplayedElementTypes.CHART.value] candles = await self._get_candles_to_display(candles_metadata, exchange_name, exchange_id, symbol, time_frame, run_start_time, run_end_time) try: graphs_by_parts[chart][commons_enums.DBTables.CANDLES.value] = candles except KeyError: graphs_by_parts[chart] = {commons_enums.DBTables.CANDLES.value: candles} # candles are assumed to be ordered if first_candle_time == 0 or first_candle_time < candles[0][commons_enums.PlotAttributes.X.value]: first_candle_time = candles[0][commons_enums.PlotAttributes.X.value] if last_candle_time == 0 or last_candle_time > candles[-1][commons_enums.PlotAttributes.X.value]: last_candle_time = candles[-1][commons_enums.PlotAttributes.X.value] except KeyError: # some table have no chart pass return first_candle_time, last_candle_time async def _get_candles_to_display(self, candles_metadata, exchange_name, exchange_id, symbol, time_frame, run_start_time, run_end_time): if candles_metadata[commons_enums.DBRows.VALUE.value] == commons_constants.LOCAL_BOT_DATA: exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) array_candles = trading_api.get_symbol_historical_candles( trading_api.get_symbol_data(exchange_manager, symbol, allow_creation=False), time_frame ) return [ { commons_enums.PlotAttributes.X.value: time * 1000, commons_enums.PlotAttributes.OPEN.value: array_candles[commons_enums.PriceIndexes.IND_PRICE_OPEN.value][index], commons_enums.PlotAttributes.HIGH.value: array_candles[commons_enums.PriceIndexes.IND_PRICE_HIGH.value][index], commons_enums.PlotAttributes.LOW.value: array_candles[commons_enums.PriceIndexes.IND_PRICE_LOW.value][index], commons_enums.PlotAttributes.CLOSE.value: array_candles[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value][index], commons_enums.PlotAttributes.VOLUME.value: array_candles[commons_enums.PriceIndexes.IND_PRICE_VOL.value][index], commons_enums.PlotAttributes.KIND.value: "candlestick", commons_enums.PlotAttributes.MODE.value: "lines", } for index, time in enumerate(array_candles[commons_enums.PriceIndexes.IND_PRICE_TIME.value]) if (run_start_time == run_end_time == 0) or run_start_time <= time <= run_end_time ] db_candles = await backtesting_api.get_all_ohlcvs(candles_metadata[commons_enums.DBRows.VALUE.value], exchange_name, symbol, commons_enums.TimeFrames(time_frame), inferior_timestamp=run_start_time if run_start_time > 0 else -1, superior_timestamp=run_end_time if run_end_time > 0 else -1) return [ { commons_enums.PlotAttributes.X.value: db_candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] * 1000, commons_enums.PlotAttributes.OPEN.value: db_candle[commons_enums.PriceIndexes.IND_PRICE_OPEN.value], commons_enums.PlotAttributes.HIGH.value: db_candle[commons_enums.PriceIndexes.IND_PRICE_HIGH.value], commons_enums.PlotAttributes.LOW.value: db_candle[commons_enums.PriceIndexes.IND_PRICE_LOW.value], commons_enums.PlotAttributes.CLOSE.value: db_candle[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value], commons_enums.PlotAttributes.VOLUME.value: db_candle[commons_enums.PriceIndexes.IND_PRICE_VOL.value], commons_enums.PlotAttributes.KIND.value: "candlestick", commons_enums.PlotAttributes.MODE.value: "lines", } for index, db_candle in enumerate(db_candles) ] def plot( self, x, y=None, open=None, high=None, low=None, close=None, volume=None, x_type="date", y_type=None, title=None, text=None, kind="scattergl", mode="lines", line_shape="linear", own_xaxis=False, own_yaxis=False, color=None, size=None, symbol=None, ): element = display.Element( kind, x, y, open=open, high=high, low=low, close=close, volume=volume, x_type=x_type, y_type=y_type, title=title, text=text, mode=mode, line_shape=line_shape, own_xaxis=own_xaxis, own_yaxis=own_yaxis, type=commons_enums.DisplayedElementTypes.CHART.value, color=color, size=size, symbol=symbol ) self.elements.append(element) def table( self, name, columns, rows, searches ): element = display.Element( None, None, None, title=name, columns=columns, rows=rows, searches=searches, type=commons_enums.DisplayedElementTypes.TABLE.value ) self.elements.append(element) def value(self, label, value): element = display.Element( None, None, None, title=label, value=str(value), type=commons_enums.DisplayedElementTypes.VALUE.value ) self.elements.append(element) def html_value(self, html): element = display.Element( None, None, None, html=html, type=commons_enums.DisplayedElementTypes.VALUE.value ) self.elements.append(element) ================================================ FILE: Meta/Keywords/scripting_library/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .data import * from .UI import * from .orders import * from .TA import * from .settings import * from .backtesting import * from .alerts import * from .configuration import * from .exchanges import * # shortcut to octobot-trading keywords from octobot_trading.modes.script_keywords.basic_keywords import * from octobot_trading.modes.script_keywords.dsl import * from octobot_trading.modes.script_keywords.context_management import Context from octobot_trading.enums import * from octobot_commons.enums import BacktestingMetadata, DBTables, DBRows ================================================ FILE: Meta/Keywords/scripting_library/alerts/__init__.py ================================================ from .notifications import * ================================================ FILE: Meta/Keywords/scripting_library/alerts/notifications.py ================================================ import octobot_services.api as services_api import octobot_services.enums as services_enum async def send_alert(title, alert_content, level: services_enum.NotificationLevel = services_enum.NotificationLevel.INFO, sound=services_enum.NotificationSound.NO_SOUND): await services_api.send_notification(services_api.create_notification(alert_content, title=title, level=level, markdown_text=alert_content, sound=sound, category=services_enum. NotificationCategory.TRADING_SCRIPT_ALERTS)) ================================================ FILE: Meta/Keywords/scripting_library/backtesting/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .metadata import * from .run_data_analysis import * from .backtesting_data_selector import * from .backtesting_settings import * from .default_backtesting_run_analysis_script import * from .backtesting_intialization import * from .backtesting_data_collector import * ================================================ FILE: Meta/Keywords/scripting_library/backtesting/backtesting_data_collector.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import contextlib import time import typing import datetime import octobot_commons import octobot_commons.constants as common_constants import octobot_commons.enums as common_enums import octobot_commons.profiles as commons_profiles import octobot_commons.timestamp_util as timestamp_util import octobot_commons.symbols import octobot_commons.logging import octobot_trading.exchanges import octobot_trading.util.test_tools.exchange_data as exchange_data_import import octobot_trading.util.test_tools.exchanges_test_tools as exchanges_test_tools import octobot.community import octobot.enums import octobot.constants as constants import tentacles.Meta.Keywords.scripting_library.errors as scr_errors import tentacles.Meta.Keywords.scripting_library.configuration as scr_configuration import tentacles.Meta.Keywords.scripting_library.exchanges as src_exchanges import tentacles.Meta.Keywords.scripting_library.constants as scr_constants import tentacles.Meta.Keywords.scripting_library.errors as errors async def init_exchange_market_status_and_populate_backtesting_exchange_data( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, backend_type: typing.Optional[octobot.enums.CommunityHistoricalBackendType] = None, ) -> exchange_data_import.ExchangeData: """ Initializes the exchange market status and populates the backtesting exchange data. If a backend type is provided, it will use the historical client to populate the backtesting exchange data. Otherwise, it will use the ccxt exchange manager to populate the backtesting exchange data. """ async with data_collector_ccxt_exchange_manager( profile_data, exchange_data ) as exchange_manager: if backend_type is not None: async with octobot.community.history_backend_client( backend_type=backend_type ) as historical_client: return await populate_backtesting_exchange_data_from_historical_client( exchange_data, profile_data, historical_client, exchange_manager.exchange_name ) return await fetch_and_populate_backtesting_exchange_data( exchange_data, profile_data, exchange_manager ) async def fetch_and_populate_backtesting_exchange_data( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, exchange_manager: octobot_trading.exchanges.ExchangeManager, ) -> exchange_data_import.ExchangeData: start_time, end_time, time_frames, symbols = _get_backtesting_run_details(profile_data) for time_frame in time_frames: await exchanges_test_tools.add_symbols_details( exchange_manager, symbols, time_frame.value, exchange_data, start_time=start_time, end_time=end_time, close_price_only=False, include_latest_candle=False, ) first_candle_times = [] for market in exchange_data.markets: first_candle_times.append(market.time[0]) _ensure_start_time(exchange_data, start_time, first_candle_times) return exchange_data def _get_backtesting_run_details( profile_data: commons_profiles.ProfileData, ) -> (float, float, list[common_enums.TimeFrames], list[str]): start_time = get_backtesting_start_time(profile_data) end_time = time.time() time_frames = [ common_enums.TimeFrames(tf) for tf in scr_configuration.get_time_frames(profile_data, for_historical_data=True) ] if ( scr_configuration.requires_price_update_timeframe(profile_data) and scr_constants.PRICE_UPDATE_TIME_FRAME.value not in time_frames ): time_frames.append(scr_constants.PRICE_UPDATE_TIME_FRAME) symbols = scr_configuration.get_traded_symbols(profile_data) return start_time, end_time, time_frames, symbols def get_backtesting_start_time( profile_data: commons_profiles.ProfileData ) -> float: return time.time() - profile_data.backtesting_context.start_time_delta def iter_fetched_ohlcvs(ohlcvs: list[list[typing.Union[float, str]]]): ohlcvs_by_symbol = {} for ohlcv in ohlcvs: time_frame = ohlcv[0] symbol = ohlcv[1] candles = ohlcv[2:] if symbol not in ohlcvs_by_symbol: ohlcvs_by_symbol[symbol] = {} if time_frame not in ohlcvs_by_symbol[symbol]: ohlcvs_by_symbol[symbol][time_frame] = [] ohlcvs_by_symbol[symbol][time_frame].append(candles) for symbol, time_frames in ohlcvs_by_symbol.items(): for time_frame, ohlcvs in time_frames.items(): yield symbol, time_frame, ohlcvs async def populate_backtesting_exchange_data_from_historical_client( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, historical_client: octobot.community.HistoricalBackendClient, exchange_name: str ) -> exchange_data_import.ExchangeData: start_time, end_time, time_frames, symbols = _get_backtesting_run_details(profile_data) first_traded_symbols, last_traded_symbols, first_historical_config_time = ( scr_configuration.get_oldest_historical_config_symbols_and_time(profile_data, start_time) ) exchange_data.exchange_details.name = profile_data.backtesting_context.exchanges[0] # todo handle multi exchanges scr_configuration.set_backtesting_portfolio(profile_data, exchange_data) exchange_data, updated_start_time = await update_backtesting_symbols_data( historical_client, profile_data, exchange_name, symbols, time_frames, exchange_data, start_time, end_time, first_traded_symbols, last_traded_symbols, first_historical_config_time ) if not scr_configuration.can_convert_ref_market_to_usd_like(exchange_data, profile_data): # usd like convert try: usd_like_time_frame = time_frames[0] symbol = await find_usd_like_symbol_from_available_history( historical_client, exchange_data.exchange_details.name, profile_data.trading.reference_market, usd_like_time_frame, updated_start_time, end_time, ) await update_backtesting_symbols_data( historical_client, profile_data, exchange_name, [symbol], [usd_like_time_frame], exchange_data, updated_start_time, end_time, [symbol], [symbol], first_historical_config_time, close_price_only=True, ) except scr_errors.InvalidBacktestingDataError as err: # can't convert ref market into usd like value _get_logger().error(f"Can't convert ref market into usd like value: {err}") except KeyError as err: # can't convert ref market into usd like value _get_logger().error( f"Can't convert ref market into usd like value: missing {err} timeframe values" ) return exchange_data async def init_backtesting_exchange_market_status_cache( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, ): async with data_collector_ccxt_exchange_manager(profile_data, exchange_data): # nothing to do, initializing the exchange manager is enough to fetch market statuses pass @contextlib.asynccontextmanager async def data_collector_ccxt_exchange_manager( profile_data: commons_profiles.ProfileData, exchange_data: exchange_data_import.ExchangeData, ): exchange_data.exchange_details.name = profile_data.backtesting_context.exchanges[0] tentacles_setup_config = scr_configuration.get_full_tentacles_setup_config() exchange_config_by_exchange = scr_configuration.get_config_by_tentacle(profile_data) async with src_exchanges.local_ccxt_exchange_manager( exchange_data, tentacles_setup_config, exchange_config_by_exchange=exchange_config_by_exchange, ) as exchange_manager: try: yield exchange_manager except Exception as err: _get_logger().exception(err) raise async def fetch_candles_history_range( historical_client: octobot.community.HistoricalBackendClient, exchange: str, symbol: str, time_frame: common_enums.TimeFrames ) -> (float, float): return await historical_client.fetch_candles_history_range(exchange, symbol, time_frame) async def find_usd_like_symbol_from_available_history( historical_client: octobot.community.HistoricalBackendClient, exchange_name: str, base: str, time_frame: common_enums.TimeFrames, first_open_time: float, last_open_time: float, ) -> str: for usd_like_coin in common_constants.USD_LIKE_COINS: symbol = octobot_commons.symbols.merge_currencies(base, usd_like_coin) first_candle_time, last_candle_time = await fetch_candles_history_range( # always use production db historical_client, exchange_name, symbol, time_frame ) if not (last_candle_time and first_candle_time): continue try: ensure_compatible_candle_time( exchange_name, symbol, time_frame, first_open_time, last_open_time, first_candle_time, last_candle_time, True, True, True, first_open_time, False ) # did not raise: symbol can be used return symbol except scr_errors.InvalidBacktestingDataError: # can't use this symbol, proceed to the next one continue raise scr_errors.InvalidBacktestingDataError( f"No USD-like up to date candles found to convert {base} into USD-like on {exchange_name} {time_frame.value} " f"for first_open_time={first_open_time} last_open_time={last_open_time}" ) async def update_backtesting_symbols_data( historical_client: octobot.community.HistoricalBackendClient, profile_data: commons_profiles.ProfileData, exchange_name: str, symbols: list, time_frames: list, exchange_data: exchange_data_import.ExchangeData, start_time: float, end_time: float, first_traded_symbols: list, last_traded_symbols: list, first_traded_symbols_time: float, close_price_only: bool = False, requires_traded_symbol_prices_at_all_time: bool = True, ) -> (exchange_data_import.ExchangeData, float): updated_start_times = [] is_custom_strategy = octobot.community.models.is_custom_strategy_profile(profile_data) # can adapt backtesting start and end time on custom strategies that require symbol prices at all time allow_any_backtesting_start_and_end_time = is_custom_strategy and requires_traded_symbol_prices_at_all_time all_ohlcvs = await historical_client.fetch_extended_candles_history( exchange_name, symbols, time_frames, start_time, end_time ) for symbol, str_time_frame, ohlcvs in iter_fetched_ohlcvs(all_ohlcvs): time_frame = common_enums.TimeFrames(str_time_frame) # do not take current incomplete candle into account last_open_time = end_time - common_enums.TimeFramesMinutes[time_frame] * common_constants.MINUTE_TO_SECONDS # When symbol in is first_traded_symbols, it should be available from the start # EXCEPT for custom strategies that might require trading pairs that don't exist for long enough # (when compatible with trading mode). # Otherwise, when it is available doesn't really matter. # If it's not available from the start, adapt start time to start as early as possible, # latest being first_traded_symbols_time. required_from_the_start = symbol in first_traded_symbols and ( requires_traded_symbol_prices_at_all_time or not is_custom_strategy ) required_till_the_end = symbol in last_traded_symbols updated_start_time = ensure_ohlcv_validity( ohlcvs, exchange_name, symbol, time_frame, start_time, last_open_time, required_from_the_start, required_till_the_end, first_traded_symbols_time, allow_any_backtesting_start_and_end_time ) if updated_start_time is not None: updated_start_times.append(updated_start_time) exchange_data.markets.append(exchange_data_import.MarketDetails( symbol=symbol, time_frame=time_frame.value, close=[ohlcv[common_enums.PriceIndexes.IND_PRICE_CLOSE.value] for ohlcv in ohlcvs], open=[ohlcv[common_enums.PriceIndexes.IND_PRICE_OPEN.value] for ohlcv in ohlcvs] if not close_price_only else [], high=[ohlcv[common_enums.PriceIndexes.IND_PRICE_HIGH.value] for ohlcv in ohlcvs] if not close_price_only else [], low=[ohlcv[common_enums.PriceIndexes.IND_PRICE_LOW.value] for ohlcv in ohlcvs] if not close_price_only else [], volume=[ohlcv[common_enums.PriceIndexes.IND_PRICE_VOL.value] for ohlcv in ohlcvs] if not close_price_only else [], time=[ohlcv[common_enums.PriceIndexes.IND_PRICE_TIME.value] for ohlcv in ohlcvs], )) updated_start_time = _ensure_start_time( exchange_data, start_time, updated_start_times ) return exchange_data, updated_start_time def _ensure_start_time( exchange_data: exchange_data_import.ExchangeData, ideal_start_time: float, updated_start_times: list[float] ) -> float: updated_start_time = max(updated_start_times) if updated_start_times else ideal_start_time if updated_start_time != ideal_start_time: # start time changed: remove extra candles _get_logger().warning( f"Adapting backtesting start time according to data availability. " f"Updated start time: {timestamp_util.convert_timestamp_to_datetime(updated_start_time)}. " f"Initial start time: {timestamp_util.convert_timestamp_to_datetime(ideal_start_time)}" ) adapt_exchange_data_for_updated_start_time(exchange_data, updated_start_time) return updated_start_time def ensure_ohlcv_validity( ohlcvs: list, exchange: str, symbol: str, time_frame: common_enums.TimeFrames, start_time: float, last_open_time: float, required_from_the_start: bool, required_till_the_end: bool, first_traded_symbols_time: float, allow_any_backtesting_start_and_end_time: bool ) -> typing.Optional[float]: if not ohlcvs: raise errors.InvalidBacktestingDataError(f"No {symbol} {time_frame.value} {exchange} OHLCV data") # ensure history is going approximately to start_time first_candle_time = ohlcvs[0][common_enums.PriceIndexes.IND_PRICE_TIME.value] last_candle_time = ohlcvs[-1][common_enums.PriceIndexes.IND_PRICE_TIME.value] return ensure_compatible_candle_time( exchange, symbol, time_frame, start_time, last_open_time, first_candle_time, last_candle_time, False, required_from_the_start, required_till_the_end, first_traded_symbols_time, allow_any_backtesting_start_and_end_time ) def adapt_exchange_data_for_updated_start_time( exchange_data: exchange_data_import.ExchangeData, first_candle_time: float ): _get_logger().info(f"Filtering out backtesting candles to start at {first_candle_time}") for market in exchange_data.markets: market.time = [ candle_time for candle_time in market.time if candle_time >= first_candle_time ] market.close = market.close[-len(market.time):] market.open = market.open[-len(market.time):] market.high = market.high[-len(market.time):] market.low = market.low[-len(market.time):] market.volume = market.volume[-len(market.time):] def ensure_compatible_candle_time( exchange: str, symbol: str, time_frame: common_enums.TimeFrames, first_open_time: float, last_open_time: float, first_candle_time: float, last_candle_time: float, allow_candles_beyond_range: bool, required_from_the_start: bool, required_till_the_end: bool, first_traded_symbols_time: float, allow_any_backtesting_start_and_end_time: bool ) -> typing.Optional[float]: adapted_start_time = None # ensure history is going approximately to first_open_time if not allow_candles_beyond_range: # first_candle_time starting before the first_open_time (more candles than required) if first_candle_time < first_open_time - constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW: raise errors.InvalidBacktestingDataError( f"{symbol} {time_frame.value} {exchange} OHLCV data starts too early " f"({first_candle_time} vs {first_open_time})" ) time_frame_seconds = common_enums.TimeFramesMinutes[time_frame] * common_constants.MINUTE_TO_SECONDS if first_candle_time > first_open_time + time_frame_seconds: if required_from_the_start: max_allowed_delayed_start = first_traded_symbols_time + constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW # missing initial candles, align start time to the first candle time when possible if allow_any_backtesting_start_and_end_time or first_candle_time < max_allowed_delayed_start: adapted_start_time = first_candle_time _get_logger().info( f"{symbol} {time_frame.value} {exchange} OHLCV data starts too late " f"({first_candle_time} vs {first_open_time}): this is acceptable, start time is adapted to " f"{first_candle_time} (delta: {datetime.timedelta(seconds=first_candle_time - first_open_time)})" ) else: raise errors.InvalidBacktestingDataError( f"{symbol} {time_frame.value} {exchange} OHLCV data starts too late " f"({first_candle_time} vs {first_open_time})" ) else: _get_logger().info( f"{symbol} {time_frame.value} {exchange} OHLCV data starts too late " f"({first_candle_time} vs {first_open_time}): this is acceptable, this symbol is not required from " f"the start" ) # ensure history is going approximately until last_open_time if not allow_candles_beyond_range: # last_open_time ending after the last_candle_time (more candles than required) if last_open_time < last_candle_time: raise errors.InvalidBacktestingDataError( f"{symbol} {time_frame.value} {exchange} OHLCV data ends too late ({last_open_time} vs {last_candle_time})" ) if last_open_time - constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW > last_candle_time: if required_till_the_end: raise errors.InvalidBacktestingDataError( f"{symbol} {time_frame.value} {exchange} OHLCV data ends too early ({last_candle_time} vs {last_open_time})" ) else: _get_logger().info( f"{symbol} {time_frame.value} {exchange} OHLCV data ends too early " f"({last_candle_time} vs {last_open_time}): this is acceptable, this symbol is not required till " f"the end of the run" ) if adapted_start_time is not None and not allow_any_backtesting_start_and_end_time: # ensure adapted_start_time is not reducing too much the global backtesting duration ideal_duration = last_open_time - first_open_time adapted_duration = last_candle_time - adapted_start_time if adapted_duration < ideal_duration * constants.BACKTESTING_MIN_DURATION_RATIO: raise errors.InvalidBacktestingDataError( f"{symbol} {time_frame.value} {exchange} OHLCV adapted backtesting start time starts too late resulting " f"in a {round(adapted_duration/common_constants.DAYS_TO_SECONDS, 1)} days backtesting duration " f"vs {round(ideal_duration/common_constants.DAYS_TO_SECONDS, 1)} ideal days. Min allowed is " f"{round(ideal_duration * constants.BACKTESTING_MIN_DURATION_RATIO / common_constants.DAYS_TO_SECONDS, 1)} days." ) return adapted_start_time def _get_logger(): return octobot_commons.logging.get_logger("ScriptingBacktestingDataCollector") ================================================ FILE: Meta/Keywords/scripting_library/backtesting/backtesting_data_selector.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_backtesting.api as backtesting_api import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_public_data as exchange_public_data def backtesting_start_time(ctx): return backtesting_api.get_backtesting_starting_time(ctx.exchange_manager.exchange.backtesting) def backtesting_first_full_candle_time(ctx): return _align_time_to_time_frame(backtesting_start_time(ctx), ctx.time_frame, False) async def backtesting_is_first_full_candle(ctx): current_t = await exchange_public_data.current_candle_time(ctx) first_c = _align_time_to_time_frame(backtesting_start_time(ctx), ctx.time_frame, False) return current_t == first_c def backtesting_end_time(ctx): return backtesting_api.get_backtesting_ending_time(ctx.exchange_manager.exchange.backtesting) def backtesting_last_full_candle_time(ctx): return _align_time_to_time_frame(backtesting_end_time(ctx), ctx.time_frame, True) def _align_time_to_time_frame(reference_time, time_frame, align_backwards): time_frame_sec = commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(time_frame)] \ * commons_constants.MINUTE_TO_SECONDS time_delta = reference_time % time_frame_sec if align_backwards: # the last full candle time is the backtesting end time moved back to the start of the candle potential_candle_time = reference_time - time_frame_sec else: # the first full candle time the backtesting start time moved forward to the start of the 1st candle potential_candle_time = reference_time - time_frame_sec time_delta = time_frame_sec - time_delta if time_delta > 0 else 0 # align back to the UTC time return potential_candle_time - time_delta if align_backwards else potential_candle_time + time_delta ================================================ FILE: Meta/Keywords/scripting_library/backtesting/backtesting_intialization.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import logging import contextlib import typing import octobot_commons.profiles as commons_profiles import octobot_commons.configuration as commons_configuration import octobot_commons.logging as commons_logging import octobot_commons.symbols as commons_symbols import octobot_commons.list_util as list_util import octobot_commons.enums as common_enums import octobot_backtesting.backtest_data import octobot_backtesting.api import octobot_tentacles_manager.configuration import octobot.backtesting.independent_backtesting import octobot.backtesting.minimal_data_importer as minimal_data_importer import octobot_trading.util.test_tools.exchange_data as exchange_data_import import octobot_trading.api import tentacles.Meta.Keywords.scripting_library as scripting_library @contextlib.asynccontextmanager async def init_and_run_backtesting( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, ) -> typing.AsyncGenerator[octobot.backtesting.independent_backtesting.IndependentBacktesting, None]: """ Initialize and run backtesting. Usage: async with init_and_run_backtesting(exchange_data, profile_data) as independent_backtesting: # use independent_backtesting to get backtesting results before it gets stopped """ async with run_backtesting( exchange_data, profile_data, scripting_library.create_backtesting_config(profile_data, exchange_data), scripting_library.get_full_tentacles_setup_config(profile_data), ) as independent_backtesting: yield independent_backtesting @contextlib.asynccontextmanager async def run_backtesting( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, backtesting_config: commons_configuration.Configuration, tentacles_config: octobot_tentacles_manager.configuration.TentaclesSetupConfiguration, enable_logs: bool = False, ) -> typing.AsyncGenerator[octobot.backtesting.independent_backtesting.IndependentBacktesting, None]: with octobot_tentacles_manager.configuration.local_get_config_proxy(scripting_library.empty_config_proxy): backtest_data = await _init_backtest_data( exchange_data, backtesting_config, tentacles_config ) independent_backtesting = None try: with commons_logging.temporary_log_level(logging.INFO): independent_backtesting = _init_independent_backtesting( exchange_data, profile_data, backtest_data, enable_logs=enable_logs ) await independent_backtesting.initialize_and_run(log_errors=True) await independent_backtesting.join_backtesting_updater(None) # independent_backtesting.log_report() # uncomment to debug yield independent_backtesting finally: if independent_backtesting is not None: with commons_logging.temporary_log_level(logging.INFO): await independent_backtesting.clear_fetched_data() await independent_backtesting.stop(memory_check=False, should_raise=False) def _init_independent_backtesting( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData, backtest_data: octobot_backtesting.backtest_data.BacktestData, enable_logs: bool = False, ) -> "octobot.backtesting.independent_backtesting.IndependentBacktesting": independent_backtesting = octobot.backtesting.independent_backtesting.IndependentBacktesting( backtest_data.config, backtest_data.tentacles_config, backtest_data.data_files, run_on_common_part_only=True, start_timestamp=None, end_timestamp=None, enable_logs=enable_logs, stop_when_finished=False, run_on_all_available_time_frames=False, enforce_total_databases_max_size_after_run=False, enable_storage=False, backtesting_data=backtest_data, config_by_tentacle={ tentacle.name: tentacle.config for tentacle in profile_data.tentacles }, services_config={}, ) independent_backtesting.symbols_to_create_exchange_classes.update({ exchange: [ commons_symbols.parse_symbol(s) for s in list_util.deduplicate([ market_details.symbol for market_details in exchange_data.markets if market_details.has_full_candles() ]) ] for exchange in [exchange_data.exchange_details.name] # TODO handle multi exchanges }) return independent_backtesting async def _init_backtest_data( exchange_data: exchange_data_import.ExchangeData, backtesting_config: commons_configuration.Configuration, tentacles_config: octobot_tentacles_manager.configuration.TentaclesSetupConfiguration, ) -> octobot_backtesting.backtest_data.BacktestData: backtest_data = await octobot_backtesting.api.create_and_init_backtest_data( [], backtesting_config.config, tentacles_config, True ) backtest_data.use_cached_markets = True await _init_importers(exchange_data, backtest_data) importer = next(iter(backtest_data.importers_by_data_file.values())) start_time, end_time = await importer.get_data_timestamp_interval() await _init_preloaded_candle_managers(exchange_data, backtest_data, start_time, end_time) return backtest_data async def _init_importers( exchange_data: exchange_data_import.ExchangeData, backtest_data: octobot_backtesting.backtest_data.BacktestData, ): backtest_data.data_files = [f"simulated_{exchange_data.exchange_details.name}_file.data"] backtest_data.default_importer = minimal_data_importer.MinimalDataImporter # type: ignore await backtest_data.initialize() for importer in backtest_data.importers_by_data_file.values(): importer.update_from_exchange_data(exchange_data) # type: ignore async def _init_preloaded_candle_managers( exchange_data: exchange_data_import.ExchangeData, backtest_data: octobot_backtesting.backtest_data.BacktestData, start_time, end_time ): for exchange_details in [exchange_data.exchange_details]: for market_details in exchange_data.markets: if not market_details.has_full_candles(): continue key = backtest_data._get_key( exchange_details.name, market_details.symbol, common_enums.TimeFrames(market_details.time_frame), start_time, end_time ) backtest_data.preloaded_candle_managers[key] = await octobot_trading.api.create_preloaded_candles_manager( market_details.get_formatted_candles() ) ================================================ FILE: Meta/Keywords/scripting_library/backtesting/backtesting_settings.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_backtesting.api as backtesting_api def set_backtesting_iteration_timeout(ctx, iteration_timeout_in_seconds: int): if ctx.exchange_manager.is_backtesting: backtesting_api.set_iteration_timeout( ctx.exchange_manager.exchange.backtesting, iteration_timeout_in_seconds ) def register_backtesting_timestamp_whitelist(ctx, timestamps, check_callback=None, append_to_whitelist=True): if check_callback is None: def _open_order_and_position_check(): # by default, avoid skipping timestamps when there are open orders or active positions if ctx.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(): return True for position in ctx.exchange_manager.exchange_personal_data.positions_manager.positions.values(): if not position.is_idle(): return True return False check_callback = _open_order_and_position_check if ctx.exchange_manager.is_backtesting and \ backtesting_api.get_backtesting_timestamp_whitelist(ctx.exchange_manager.exchange.backtesting) \ != sorted(set(timestamps)): return backtesting_api.register_backtesting_timestamp_whitelist( ctx.exchange_manager.exchange.backtesting, timestamps, check_callback, append_to_whitelist=append_to_whitelist ) def is_registered_backtesting_timestamp_whitelist(ctx): return ctx.exchange_manager.is_backtesting and \ backtesting_api.get_backtesting_timestamp_whitelist(ctx.exchange_manager.exchange.backtesting) is not None ================================================ FILE: Meta/Keywords/scripting_library/backtesting/default_backtesting_run_analysis_script.py ================================================ import datetime as datetime import json as json import octobot_commons.enums as commons_enums import octobot_commons.pretty_printer as pretty_printer import octobot_services.constants as services_constants import tentacles.Meta.Keywords.scripting_library.backtesting.run_data_analysis as run_data_analysis import octobot_trading.modes.script_keywords as script_keywords async def default_backtesting_analysis_script(ctx: script_keywords.Context): async with ctx.backtesting_results() as (run_data, run_display): historical_values = await run_data_analysis.load_historical_values(run_data, None) if ctx.backtesting_analysis_settings["plot_pnl_on_main_chart"]: with run_display.part("main-chart") as part: try: await run_data_analysis.plot_historical_portfolio_value( run_data, part, historical_values=historical_values, ) await run_data_analysis.plot_historical_pnl_value( run_data, part, x_as_trade_count=False, own_yaxis=True, include_unitary=ctx.backtesting_analysis_settings["plot_trade_gains_on_main_chart"], historical_values=historical_values, ) except Exception as err: ctx.logger.exception(err, True, f"Error when computing main chant graphs {err}") with run_display.part("backtesting-run-overview") as part: try: if ctx.backtesting_analysis_settings.get("plot_hist_portfolio_on_backtesting_chart", True): await run_data_analysis.plot_historical_portfolio_value( run_data, part, historical_values=historical_values, ) if ctx.backtesting_analysis_settings["plot_pnl_on_backtesting_chart"]: await run_data_analysis.plot_historical_pnl_value( run_data, part, x_as_trade_count=False, own_yaxis=True, include_unitary=ctx.backtesting_analysis_settings["plot_trade_gains_on_backtesting_chart"], historical_values=historical_values, ) if ctx.backtesting_analysis_settings["plot_best_case_growth_on_backtesting_chart"]: await run_data_analysis.plot_best_case_growth( run_data, part, x_as_trade_count=True, own_yaxis=False, historical_values=historical_values, ) if ctx.backtesting_analysis_settings["plot_funding_fees_on_backtesting_chart"]: await run_data_analysis.plot_historical_funding_fees( run_data, part, own_yaxis=True, ) if ctx.backtesting_analysis_settings["plot_wins_and_losses_count_on_backtesting_chart"]: await run_data_analysis.plot_historical_wins_and_losses( run_data, part, own_yaxis=True, x_as_trade_count=False, historical_values=historical_values, ) if ctx.backtesting_analysis_settings["plot_win_rate_on_backtesting_chart"]: await run_data_analysis.plot_historical_win_rates( run_data, part, own_yaxis=True, x_as_trade_count=False, historical_values=historical_values, ) # await plot_withdrawals(run_data, part) except Exception as err: ctx.logger.exception(err, True, f"Error when computing run overview graphs {err}") if ctx.backtesting_analysis_settings["display_backtest_details"]: with run_display.part("backtesting-details", "value") as part: try: backtesting_report = await get_backtesting_report_template( run_data, ctx.backtesting_analysis_settings, historical_values ) await run_data_analysis.display_html(part, backtesting_report) except Exception as err: ctx.logger.exception(err, True, f"Error when computing details part {err}") if ctx.backtesting_analysis_settings["display_trades_and_positions"]: with run_display.part("list-of-trades-part", "table") as part: try: await run_data_analysis.plot_trades(run_data, part, historical_values=historical_values) await run_data_analysis.plot_orders(run_data, part, historical_values=historical_values) await run_data_analysis.plot_positions(run_data, part) # await plot_table(run_data, part, "SMA 1") # plot any cache key as a table except Exception as err: ctx.logger.exception(err, True, f"Error when computing trades part {err}") return run_display async def get_backtesting_report_template(run_data, backtesting_analysis_settings, historical_values): price_data, _, _, _, _, metadata = historical_values optimizer_id_display = get_column_display(commons_enums.BacktestingMetadata.OPTIMIZER_ID.value, commons_enums.BacktestingMetadata.OPTIMIZER_ID.value) \ if commons_enums.BacktestingMetadata.OPTIMIZER_ID.value in metadata.keys() else "" paid_fees_display = get_column_display(services_constants.PAID_FEES_STR, metadata["paid_fees"]) if "paid_fees" in metadata.keys() else "" performance_summary = "" reference_market = metadata[commons_enums.DBRows.REFERENCE_MARKET.value] if backtesting_analysis_settings.get("display_backtest_details_performances", True): start_portfolio_value, end_portfolio_value = await run_data_analysis.get_portfolio_values(run_data) gains = f"{pretty_printer.get_min_string_from_number(metadata[commons_enums.BacktestingMetadata.GAINS.value])} " \ f"({pretty_printer.get_min_string_from_number(metadata[commons_enums.BacktestingMetadata.PERCENT_GAINS.value])}%)" performance_summary \ += get_section_display("Performance", get_column_display(commons_enums.BacktestingMetadata.START_PORTFOLIO.value, get_portfolio_display( metadata[commons_enums.BacktestingMetadata.START_PORTFOLIO.value] )) + get_column_display(commons_enums.BacktestingMetadata.END_PORTFOLIO.value, get_portfolio_display( metadata[ commons_enums.BacktestingMetadata.END_PORTFOLIO.value])) + get_column_display(f"{commons_enums.BacktestingMetadata.START_PORTFOLIO.value} " f"{reference_market} value", start_portfolio_value) + get_column_display(f"{commons_enums.BacktestingMetadata.END_PORTFOLIO.value} " f"{reference_market} value", end_portfolio_value) + get_column_display(f"{reference_market} gains", gains) + get_column_display( commons_enums.BacktestingMetadata.MARKETS_PROFITABILITY.value, metadata.get(commons_enums.BacktestingMetadata.MARKETS_PROFITABILITY.value, {}) ) + get_column_display( commons_enums.BacktestingMetadata.TRADES.value + " (entries and exits)", metadata[commons_enums.BacktestingMetadata.TRADES.value]) # todo fix those values # + get_column_display(commons_enums.BacktestingMetadata.ENTRIES.value, # metadata[commons_enums.BacktestingMetadata.ENTRIES.value]) + # get_column_display(commons_enums.BacktestingMetadata.WINS.value, # metadata[commons_enums.BacktestingMetadata.WINS.value]) + # get_column_display(commons_enums.BacktestingMetadata.LOSES.value, # metadata[commons_enums.BacktestingMetadata.LOSES.value]) + # get_column_display(commons_enums.BacktestingMetadata.WIN_RATE.value, # metadata[commons_enums.BacktestingMetadata.WIN_RATE.value]) + # get_column_display(commons_enums.BacktestingMetadata.DRAW_DOWN.value, # metadata[commons_enums.BacktestingMetadata.DRAW_DOWN.value]) + # get_column_display( # commons_enums.BacktestingMetadata.COEFFICIENT_OF_DETERMINATION_MAX_BALANCE.value, # metadata[commons_enums.BacktestingMetadata # .COEFFICIENT_OF_DETERMINATION_MAX_BALANCE.value]) + # paid_fees_display ) if backtesting_analysis_settings.get("display_backtest_details_general", True): performance_summary \ += get_section_display("General", get_column_display(commons_enums.BacktestingMetadata.NAME.value, metadata[commons_enums.BacktestingMetadata.NAME.value]) + get_column_display(commons_enums.BacktestingMetadata.OPTIMIZATION_CAMPAIGN.value, metadata[commons_enums.BacktestingMetadata. OPTIMIZATION_CAMPAIGN.value]) + optimizer_id_display + get_column_display(commons_enums.BacktestingMetadata.ID.value, metadata[commons_enums.BacktestingMetadata.ID.value]) + get_column_display(commons_enums.DBRows.EXCHANGES.value, metadata[commons_enums.DBRows.EXCHANGES.value]) + get_column_display(commons_enums.BacktestingMetadata.BACKTESTING_FILES.value, metadata[commons_enums.BacktestingMetadata.BACKTESTING_FILES.value])) if backtesting_analysis_settings.get("display_backtest_details_details", True): performance_summary \ += get_section_display("Details", get_column_display(commons_enums.BacktestingMetadata.TIME_FRAMES.value, get_badges_from_list( metadata[commons_enums.BacktestingMetadata.TIME_FRAMES.value])) + get_column_display(commons_enums.BacktestingMetadata.START_TIME.value, datetime.datetime.fromtimestamp( metadata[commons_enums.DBRows.START_TIME.value])) + get_column_display(commons_enums.BacktestingMetadata.END_TIME.value, datetime.datetime.fromtimestamp( metadata[commons_enums.DBRows.END_TIME.value])) + get_column_display(commons_enums.BacktestingMetadata.SYMBOLS.value, get_badges_from_list( metadata[commons_enums.BacktestingMetadata.SYMBOLS.value])) + get_column_display(f"{commons_enums.BacktestingMetadata.DURATION.value} (s)", metadata[commons_enums.BacktestingMetadata.DURATION.value]) + get_column_display(commons_enums.BacktestingMetadata.LEVERAGE.value, metadata[commons_enums.BacktestingMetadata.LEVERAGE.value]) + get_column_display("Backtesting time", datetime.datetime.fromtimestamp( metadata[commons_enums.BacktestingMetadata.TIMESTAMP.value])) ) if backtesting_analysis_settings.get("display_backtest_details_strategy_settings", True): performance_summary \ += get_section_display("Strategy Settings", get_user_inputs_display(metadata) ) return performance_summary def get_section_display(title, content): return f'''

{title}

{content}
''' def get_column_display(title, value): return f'''
{title}
{', '.join(value) if (isinstance(value, list) and value and isinstance(value[0], (int, float, str))) else pretty_printer.get_min_string_from_number(value) if isinstance(value, float) else ', '.join(f"{key}: {val}" for key, val in value.items()) if isinstance(value, dict) else value}
''' def get_badges_from_list(_list): _html = "" for _item in _list: _html += f'{_item}' return _html def get_portfolio_display(_dict): _html = "" _dict_str = _dict.replace("\'", '"') _dict_str = json.loads(_dict_str) for _key in _dict_str: _html += f'{_key}: {pretty_printer.get_min_string_from_number(_dict_str[_key]["total"])}' return _html def get_user_inputs_display(metadata): content = "" for _evaluator in metadata['user inputs']: _section_content = "" for input_name in metadata['user inputs'][_evaluator]: _section_content += get_column_display(input_name, metadata['user inputs'][_evaluator][input_name]) content += get_section_display(_evaluator, _section_content) return content ================================================ FILE: Meta/Keywords/scripting_library/backtesting/metadata.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.databases as databases import octobot_commons.errors as commons_errors import octobot_commons.enums as commons_enums import tentacles.Meta.Keywords.scripting_library.data as data def set_script_name(ctx, name): ctx.tentacle.script_name = name async def _read_backtesting_metadata(optimizer_run_dbs_identifier, metadata_list, optimizer_id): async with data.MetadataReader.database(optimizer_run_dbs_identifier.get_backtesting_metadata_identifier()) \ as reader: try: metadata = await reader.read() for metadata_element in metadata: metadata_element[commons_enums.BacktestingMetadata.OPTIMIZER_ID.value] = optimizer_id metadata_list += metadata except commons_errors.DatabaseNotFoundError: pass async def read_metadata(runs_to_load_settings, trading_mode, include_optimizer_runs=False): metadata = [] optimizer_run_dbs_identifiers = [] run_dbs_identifier = databases.RunDatabasesIdentifier(trading_mode) try: campaigns_to_load = runs_to_load_settings["campaigns"] except KeyError: campaigns_to_load = runs_to_load_settings["campaigns"] = {} available_campaigns = await run_dbs_identifier.get_optimization_campaign_names() campaigns = {} for optimization_campaign_name in available_campaigns: if optimization_campaign_name in campaigns_to_load: if campaigns_to_load[optimization_campaign_name]: campaigns[optimization_campaign_name] = True else: campaigns[optimization_campaign_name] = False continue else: campaigns[optimization_campaign_name] = True backtesting_run_dbs_identifier = databases.RunDatabasesIdentifier(trading_mode, optimization_campaign_name, backtesting_id="1") if include_optimizer_runs: optimizer_ids = await backtesting_run_dbs_identifier.get_optimizer_run_ids() if optimizer_ids: optimizer_run_dbs_identifiers = [ databases.RunDatabasesIdentifier(trading_mode, optimization_campaign_name, optimizer_id=optimizer_id) for optimizer_id in optimizer_ids] try: await _read_backtesting_metadata(backtesting_run_dbs_identifier, metadata, 0) except commons_errors.DatabaseNotFoundError: pass for optimizer_run_dbs_identifier in optimizer_run_dbs_identifiers: await _read_backtesting_metadata(optimizer_run_dbs_identifier, metadata, optimizer_run_dbs_identifier.optimizer_id) return campaigns, metadata async def _read_bot_recording_metadata(run_dbs_identifier, metadata_list): async with data.MetadataReader.database(run_dbs_identifier.get_bot_live_metadata_identifier()) \ as reader: try: metadata = await reader.read() metadata_list += metadata except commons_errors.DatabaseNotFoundError: pass async def read_bot_recording_runs_metadata(trading_mode): metadata = [] run_dbs_identifier = databases.RunDatabasesIdentifier(trading_mode) try: await _read_bot_recording_metadata(run_dbs_identifier, metadata) except commons_errors.DatabaseNotFoundError: pass return metadata ================================================ FILE: Meta/Keywords/scripting_library/backtesting/run_data_analysis.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import json import sortedcontainers import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.exchange_data as trading_exchange_data import octobot_trading.personal_data as trading_personal_data import octobot_trading.personal_data.portfolios.portfolio_util as portfolio_util import octobot_trading.api as trading_api import octobot_backtesting.api as backtesting_api import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.constants import octobot_commons.databases as databases import octobot_commons.enums as commons_enums import octobot_commons.errors as commons_errors import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.logging def get_logger(): return octobot_commons.logging.get_logger("BacktestingRunData") async def get_candles(candles_sources, exchange, symbol, time_frame, metadata): return await backtesting_api.get_all_ohlcvs(candles_sources[0][commons_enums.DBRows.VALUE.value], exchange, symbol, commons_enums.TimeFrames(time_frame), inferior_timestamp=metadata[commons_enums.DBRows.START_TIME.value], superior_timestamp=metadata[commons_enums.DBRows.END_TIME.value]) async def get_trades(meta_database, metadata, symbol): account_type = trading_api.get_account_type_from_run_metadata(metadata) return await meta_database.get_trades_db(account_type).select( commons_enums.DBTables.TRADES.value, (await meta_database.get_trades_db(account_type).search()).symbol == symbol ) async def get_metadata(meta_database): return (await meta_database.get_run_db().all(commons_enums.DBTables.METADATA.value))[0] async def get_transactions(meta_database, transaction_type=None, transaction_types=None): account_type = trading_api.get_account_type_from_run_metadata(await get_metadata(meta_database)) if transaction_type is not None: query = (await meta_database.get_transactions_db(account_type).search()).type == transaction_type elif transaction_types is not None: query = (await meta_database.get_transactions_db(account_type).search()).type.one_of(transaction_types) else: return await meta_database.get_transactions_db(account_type).all(commons_enums.DBTables.TRANSACTIONS.value) return await meta_database.get_transactions_db(account_type).select(commons_enums.DBTables.TRANSACTIONS.value, query) async def get_starting_portfolio(meta_database) -> dict: portfolio = (await meta_database.get_run_db().all(commons_enums.DBTables.METADATA.value))[0][ commons_enums.BacktestingMetadata.START_PORTFOLIO.value] return json.loads(portfolio.replace("'", '"')) async def load_historical_values(meta_database, exchange, with_candles=True, with_trades=True, with_portfolio=True, time_frame=None): price_data = {} trades_data = {} moving_portfolio_data = {} trading_type = "spot" metadata = {} run_global_metadata = {} try: starting_portfolio = await get_starting_portfolio(meta_database) metadata = await get_metadata(meta_database) run_global_metadata = await meta_database.get_backtesting_metadata_from_run() exchange = exchange or meta_database.run_dbs_identifier.context.exchange_name \ or metadata[commons_enums.DBRows.EXCHANGES.value][0] # TODO handle multi exchanges ref_market = metadata[commons_enums.DBRows.REFERENCE_MARKET.value] trading_type = metadata[commons_enums.DBRows.TRADING_TYPE.value] contracts = metadata[commons_enums.DBRows.FUTURE_CONTRACTS.value][exchange] if trading_type == "future" else {} # init data for pair in run_global_metadata[commons_enums.DBRows.SYMBOLS.value]: symbol = symbol_util.parse_symbol(pair).base is_inverse_contract = trading_type == "future" and trading_api.is_inverse_future_contract( trading_enums.FutureContractType(contracts[pair]["contract_type"]) ) if symbol != ref_market or is_inverse_contract: candles_sources = await meta_database.get_symbol_db(exchange, pair).all( commons_enums.DBTables.CANDLES_SOURCE.value ) if time_frame is None: time_frames = [source[commons_enums.DBRows.TIME_FRAME.value] for source in candles_sources] time_frame = time_frame_manager.find_min_time_frame(time_frames) if time_frames else time_frame if with_candles and pair not in price_data: # convert candles timestamp in millis raw_candles = await get_candles(candles_sources, exchange, pair, time_frame, metadata) for candle in raw_candles: candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] = \ candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] * 1000 price_data[pair] = raw_candles if with_trades and pair not in trades_data: trades_data[pair] = await get_trades(meta_database, metadata, pair) if with_portfolio: try: moving_portfolio_data[symbol] = starting_portfolio[symbol][ octobot_commons.constants.PORTFOLIO_TOTAL] except KeyError: moving_portfolio_data[symbol] = 0 try: moving_portfolio_data[ref_market] = starting_portfolio[ref_market][ octobot_commons.constants.PORTFOLIO_TOTAL] except KeyError: moving_portfolio_data[ref_market] = 0 except IndexError: pass return price_data, trades_data, moving_portfolio_data, trading_type, metadata, run_global_metadata async def backtesting_data(meta_database, data_label): metadata_from_run = await meta_database.get_backtesting_metadata_from_run() for key, value in metadata_from_run.items(): if key == data_label: return value account_type = trading_api.get_account_type_from_run_metadata(metadata_from_run) for reader in meta_database.all_basic_run_db(account_type): for table in await reader.tables(): if table == data_label: return await reader.all(table) for row in await reader.all(table): for key, value in row.items(): if key == data_label: return value return None async def _get_grouped_funding_fees(meta_database, group_key): funding_fees_history = await get_transactions(meta_database, transaction_type=trading_enums.TransactionType.FUNDING_FEE.value) funding_fees_history = sorted(funding_fees_history, key=lambda f: f[commons_enums.PlotAttributes.X.value]) funding_fees_history_by_key = {} for funding_fee in funding_fees_history: try: funding_fees_history_by_key[funding_fee[group_key]].append(funding_fee) except KeyError: funding_fees_history_by_key[funding_fee[group_key]] = [funding_fee] return funding_fees_history_by_key async def plot_historical_funding_fees(meta_database, plotted_element, own_yaxis=True): funding_fees_history_by_currency = await _get_grouped_funding_fees( meta_database, trading_enums.FeePropertyColumns.CURRENCY.value ) for currency, fees in funding_fees_history_by_currency.items(): cumulative_fees = [] previous_fee = 0 for fee in fees: cumulated_fee = fee["quantity"] + previous_fee cumulative_fees.append(cumulated_fee) previous_fee = cumulated_fee plotted_element.plot( mode="scatter", x=[fee[commons_enums.PlotAttributes.X.value] for fee in fees], y=cumulative_fees, title=f"{currency} paid funding fees", own_yaxis=own_yaxis, line_shape="hv") def _position_factory(symbol, contract_data): # TODO: historical unrealized pnl, maybe find a better solution that this import mock class _TraderMock: def __init__(self): self.exchange_manager = mock.Mock() self.simulate = True contract = trading_exchange_data.FutureContract( symbol, trading_enums.MarginType(contract_data["margin_type"]), trading_enums.FutureContractType(contract_data["contract_type"]) ) return trading_personal_data.create_position_from_type(_TraderMock(), contract) def _evaluate_portfolio(portfolio, price_data, use_start_value): handled_currencies = [] value = 0 vals = {} for pair, candles in price_data.items(): candle = candles[0 if use_start_value else len(candles) - 1] symbol, ref_market = symbol_util.parse_symbol(pair).base_and_quote() if symbol not in handled_currencies: value += portfolio.get(symbol, {}).get(octobot_commons.constants.PORTFOLIO_TOTAL, 0) * candle[ commons_enums.PriceIndexes.IND_PRICE_OPEN.value ] vals[symbol] = candle[ commons_enums.PriceIndexes.IND_PRICE_OPEN.value ] handled_currencies.append(symbol) if ref_market not in handled_currencies: value += portfolio.get(ref_market, {}).get(octobot_commons.constants.PORTFOLIO_TOTAL, 0) handled_currencies.append(ref_market) return value async def get_portfolio_values(meta_database, exchange=None, historical_values=None): price_data, trades_data, moving_portfolio_data, trading_type, metadata, _ = \ historical_values or await load_historical_values(meta_database, exchange, with_portfolio=False, with_trades=False) starting_portfolio = json.loads(metadata[commons_enums.BacktestingMetadata.START_PORTFOLIO.value].replace("'", '"')) ending_portfolio = json.loads(metadata[commons_enums.BacktestingMetadata.END_PORTFOLIO.value].replace("'", '"')) return _evaluate_portfolio( starting_portfolio, price_data, True, ), _evaluate_portfolio( ending_portfolio, price_data, False, ) async def plot_historical_portfolio_value( meta_database, plotted_element, exchange=None, own_yaxis=False, historical_values=None ): price_data, trades_data, moving_portfolio_data, trading_type, metadata, _ = \ historical_values or await load_historical_values(meta_database, exchange) price_data_by_time = {} for symbol, candles in price_data.items(): price_data_by_time[symbol] = { candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value]: candle for candle in candles } if trading_type == "future": # TODO: historical unrealized pnl pass for pair in trades_data: trades_data[pair] = sorted(trades_data[pair], key=lambda tr: tr[commons_enums.PlotAttributes.X.value]) funding_fees_history_by_pair = await _get_grouped_funding_fees(meta_database, commons_enums.DBRows.SYMBOL.value) value_data = sortedcontainers.SortedDict() pairs = list(trades_data) if pairs: pair = pairs[0] candles = price_data_by_time[pair] value_data = sortedcontainers.SortedDict({ t: 0 for t in candles }) trade_index_by_pair = {p: 0 for p in pairs} funding_fees_index_by_pair = {p: 0 for p in pairs} # TODO multi exchanges exchange_name = metadata[commons_enums.DBRows.EXCHANGES.value][0] # TODO hedge mode with multi position by pair # if metadata[commons_enums.DBRows.FUTURE_CONTRACTS.value] and \ # exchange_name in metadata[commons_enums.DBRows.FUTURE_CONTRACTS.value]: # positions_by_pair = { # pair: _position_factory(pair, # metadata[commons_enums.DBRows.FUTURE_CONTRACTS.value][exchange_name][pair]) # for pair in pairs # } # else: # positions_by_pair = {} # TODO update position instead of portfolio when filled orders and apply position unrealized pnl to portfolio for candle_time, ref_candle in candles.items(): current_candles = {} for pair in pairs: if candle_time not in price_data_by_time[pair]: # no price data for this time in this pair continue other_candle = price_data_by_time[pair][candle_time] current_candles[pair] = other_candle symbol, ref_market = symbol_util.parse_symbol(pair).base_and_quote() moving_portfolio_data[ref_market] = moving_portfolio_data.get(ref_market, 0) moving_portfolio_data[symbol] = moving_portfolio_data.get(symbol, 0) # part 1: compute portfolio total value after trade update when any # 1.1: trades # start iteration where it last stopped to reduce complexity for trade_index, trade in enumerate(trades_data[pair][trade_index_by_pair[pair]:]): # handle trades that are both older and at the current candle starting from the last trade index # (older trades to handle the ones that might be from candles we dont have data on) if trade[commons_enums.PlotAttributes.X.value] <= candle_time: if trade[commons_enums.PlotAttributes.SIDE.value] == trading_enums.TradeOrderSide.SELL.value: moving_portfolio_data[symbol] -= trade[commons_enums.PlotAttributes.VOLUME.value] moving_portfolio_data[ref_market] += trade[commons_enums.PlotAttributes.VOLUME.value] * \ trade[commons_enums.PlotAttributes.Y.value] else: moving_portfolio_data[symbol] += trade[commons_enums.PlotAttributes.VOLUME.value] moving_portfolio_data[ref_market] -= trade[commons_enums.PlotAttributes.VOLUME.value] * \ trade[commons_enums.PlotAttributes.Y.value] moving_portfolio_data[trade[commons_enums.DBRows.FEES_CURRENCY.value]] -= \ trade[commons_enums.DBRows.FEES_AMOUNT.value] # last trade case: as there is not trade afterwards, the next condition would never be filled, # force trade_index_by_pair[pair] increment if all(it_trade[commons_enums.PlotAttributes.X.value] == trade[commons_enums.PlotAttributes.X.value] for it_trade in trades_data[pair][trade_index_by_pair[pair]:]): trade_index_by_pair[pair] += 1 break if trade[commons_enums.PlotAttributes.X.value] > \ ref_candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value]: # no need to continue iterating, save current index for new candle trade_index_by_pair[pair] += trade_index break # 1.2: funding fees # start iteration where it last stopped to reduce complexity for funding_fee_index, funding_fee \ in enumerate(funding_fees_history_by_pair.get(pair, [])[funding_fees_index_by_pair[pair]:]): if funding_fee[commons_enums.PlotAttributes.X.value] == candle_time: moving_portfolio_data[funding_fee[trading_enums.FeePropertyColumns.CURRENCY.value]] -= \ funding_fee["quantity"] if funding_fee[commons_enums.PlotAttributes.X.value] > candle_time: # no need to continue iterating, save current index for new candle funding_fees_index_by_pair[pair] = funding_fee_index # TODO break # part 2: now that portfolio is up-to-date, compute portfolio total value handled_currencies = [] for pair, other_candle in current_candles.items(): symbol, ref_market = symbol_util.parse_symbol(pair).base_and_quote() if symbol not in handled_currencies: value_data[candle_time] = \ value_data[candle_time] + \ moving_portfolio_data[symbol] * other_candle[ commons_enums.PriceIndexes.IND_PRICE_OPEN.value ] handled_currencies.append(symbol) if ref_market not in handled_currencies: value_data[candle_time] = value_data[candle_time] + moving_portfolio_data[ref_market] handled_currencies.append(ref_market) plotted_element.plot( mode="scatter", x=list(value_data.keys()), y=list(value_data.values()), title="Portfolio value", own_yaxis=own_yaxis ) def _read_pnl_from_trades(x_data, pnl_data, cumulative_pnl_data, trades_history, x_as_trade_count): buy_order_volume_by_price_by_currency = { symbol_util.parse_symbol(symbol).base: {} for symbol in trades_history.keys() } all_trades = [] buy_fees = 0 sell_fees = 0 for trades in trades_history.values(): all_trades += trades for trade in sorted(all_trades, key=lambda x: x[commons_enums.PlotAttributes.X.value]): currency, ref_market = symbol_util.parse_symbol(trade[commons_enums.DBRows.SYMBOL.value]).base_and_quote() trade_volume = trade[commons_enums.PlotAttributes.VOLUME.value] buy_order_volume_by_price = buy_order_volume_by_price_by_currency[currency] if trade[commons_enums.PlotAttributes.SIDE.value] == trading_enums.TradeOrderSide.BUY.value: fees = trade[commons_enums.DBRows.FEES_AMOUNT.value] fees_multiplier = 1 if trade[commons_enums.DBRows.FEES_CURRENCY.value] == currency \ else 1 / trade[commons_enums.PlotAttributes.Y.value] paid_fees = fees * fees_multiplier buy_fees += paid_fees * trade[commons_enums.PlotAttributes.Y.value] buy_cost = trade_volume * trade[commons_enums.PlotAttributes.Y.value] if trade[commons_enums.PlotAttributes.Y.value] in buy_order_volume_by_price: buy_order_volume_by_price[buy_cost / (trade_volume - paid_fees)] += trade_volume - paid_fees else: buy_order_volume_by_price[buy_cost / (trade_volume - paid_fees)] = trade_volume - paid_fees elif trade[commons_enums.PlotAttributes.SIDE.value] == trading_enums.TradeOrderSide.SELL.value: remaining_sell_volume = trade_volume volume_by_bought_prices = {} for order_price in list(buy_order_volume_by_price.keys()): if buy_order_volume_by_price[order_price] > remaining_sell_volume: buy_order_volume_by_price[order_price] -= remaining_sell_volume volume_by_bought_prices[order_price] = remaining_sell_volume remaining_sell_volume = 0 elif buy_order_volume_by_price[order_price] == remaining_sell_volume: buy_order_volume_by_price.pop(order_price) volume_by_bought_prices[order_price] = remaining_sell_volume remaining_sell_volume = 0 else: # buy_order_volume_by_price[order_price] < remaining_sell_volume buy_volume = buy_order_volume_by_price.pop(order_price) volume_by_bought_prices[order_price] = buy_volume remaining_sell_volume -= buy_volume if remaining_sell_volume <= 0: break if volume_by_bought_prices: # use total_bought_volume only to avoid taking pre-existing open positions into account # (ex if started with already 10 btc) # total obtained (in ref market) – sell order fees – buy costs (in ref market before fees) buy_cost = sum(price * volume for price, volume in volume_by_bought_prices.items()) fees = trade[commons_enums.DBRows.FEES_AMOUNT.value] fees_multiplier = 1 if trade[commons_enums.DBRows.FEES_CURRENCY.value] == ref_market \ else trade[commons_enums.PlotAttributes.Y.value] sell_fees += fees * fees_multiplier local_pnl = trade[commons_enums.PlotAttributes.Y.value] * \ trade_volume - (fees * fees_multiplier) - buy_cost pnl_data.append(local_pnl) cumulative_pnl_data.append(local_pnl + cumulative_pnl_data[-1]) if x_as_trade_count: x_data.append(len(pnl_data) - 1) else: x_data.append(trade[commons_enums.PlotAttributes.X.value]) else: get_logger().error(f"Unknown trade side: {trade}") def _read_pnl_from_transactions(x_data, pnl_data, cumulative_pnl_data, trading_transactions_history, x_as_trade_count): previous_value = 0 for transaction in trading_transactions_history: transaction_pnl = 0 if transaction["realised_pnl"] is None else transaction["realised_pnl"] transaction_quantity = 0 if transaction["quantity"] is None else transaction["quantity"] local_quantity = transaction_pnl + transaction_quantity cumulated_pnl = local_quantity + previous_value pnl_data.append(local_quantity) cumulative_pnl_data.append(cumulated_pnl) previous_value = cumulated_pnl if x_as_trade_count: x_data.append(len(pnl_data) - 1) else: x_data.append(transaction[commons_enums.PlotAttributes.X.value]) async def _get_historical_pnl(meta_database, plotted_element, include_cumulative, include_unitary, exchange=None, x_as_trade_count=True, own_yaxis=False, historical_values=None): # PNL: # 1. open position: consider position opening fee from PNL # 2. close position: consider closed amount + closing fee into PNL # what is a trade ? # futures: when position going to 0 (from long/short) => trade is closed # spot: when position lowered => trade is closed price_data, trades_data, _, _, _, _ = historical_values or await load_historical_values(meta_database, exchange) if not (price_data and next(iter(price_data.values()))): return x_data = [0 if x_as_trade_count else next(iter(price_data.values()))[0][commons_enums.PriceIndexes.IND_PRICE_TIME.value]] pnl_data = [0] cumulative_pnl_data = [0] trading_transactions_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.TRADING_FEE.value, trading_enums.TransactionType.FUNDING_FEE.value, trading_enums.TransactionType.REALISED_PNL.value, trading_enums.TransactionType.CLOSE_REALISED_PNL.value) ) if trading_transactions_history: # can rely on pnl history _read_pnl_from_transactions(x_data, pnl_data, cumulative_pnl_data, trading_transactions_history, x_as_trade_count) else: # recreate pnl history from trades _read_pnl_from_trades(x_data, pnl_data, cumulative_pnl_data, trades_data, x_as_trade_count) if not x_as_trade_count: # x axis is time: add a value at the end of the axis if missing to avoid a missing values at the end feeling last_time_value = next(iter(price_data.values()))[-1][commons_enums.PriceIndexes.IND_PRICE_TIME.value] if x_data[-1] != last_time_value: # append the latest value at the end of the x axis x_data.append(last_time_value) pnl_data.append(0) cumulative_pnl_data.append(cumulative_pnl_data[-1]) if include_unitary: plotted_element.plot( kind="bar", x=x_data, y=pnl_data, x_type="tick0" if x_as_trade_count else "date", title="P&L per trade", own_yaxis=own_yaxis) if include_cumulative: plotted_element.plot( mode="scatter", x=x_data, y=cumulative_pnl_data, x_type="tick0" if x_as_trade_count else "date", title="Cumulative P&L", own_yaxis=own_yaxis, line_shape="hv") async def total_paid_fees(meta_database, all_trades): paid_fees = 0 fees_currency = None trading_transactions_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.FUNDING_FEE.value,) ) if trading_transactions_history: for transaction in trading_transactions_history: if fees_currency is None: fees_currency = transaction["currency"] if transaction["currency"] != fees_currency: get_logger().error(f"Unknown funding fee value: {transaction}") else: # - because funding fees are stored as negative number when paid (positive when "gained") paid_fees -= transaction["quantity"] for trade in all_trades: currency = symbol_util.parse_symbol(trade[commons_enums.DBRows.SYMBOL.value]).base if trade[commons_enums.DBRows.FEES_CURRENCY.value] == currency: if trade[commons_enums.DBRows.FEES_CURRENCY.value] == fees_currency: paid_fees += trade[commons_enums.DBRows.FEES_AMOUNT.value] else: paid_fees += trade[commons_enums.DBRows.FEES_AMOUNT.value] * \ trade[commons_enums.PlotAttributes.Y.value] else: if trade[commons_enums.DBRows.FEES_CURRENCY.value] == fees_currency: paid_fees += trade[commons_enums.DBRows.FEES_AMOUNT.value] / \ trade[commons_enums.PlotAttributes.Y.value] else: paid_fees += trade[commons_enums.DBRows.FEES_AMOUNT.value] return paid_fees async def plot_historical_pnl_value(meta_database, plotted_element, exchange=None, x_as_trade_count=True, own_yaxis=False, include_cumulative=True, include_unitary=True, historical_values=None): return await _get_historical_pnl(meta_database, plotted_element, include_cumulative, include_unitary, exchange=exchange, x_as_trade_count=x_as_trade_count, own_yaxis=own_yaxis, historical_values=historical_values) def _plot_table_data(data, plotted_element, data_name, additional_key_to_label, additional_columns, datum_columns_callback): if not data: get_logger().debug(f"Nothing to create a table from when reading {data_name}") return column_render = _get_default_column_render() types = _get_default_types() key_to_label = { **plotted_element.TABLE_KEY_TO_COLUMN, **additional_key_to_label } columns = _get_default_columns(plotted_element, data, column_render, key_to_label) + additional_columns if datum_columns_callback: for datum in data: datum_columns_callback(datum) rows = _get_default_rows(data, columns) searches = _get_default_searches(columns, types) plotted_element.table( data_name, columns=columns, rows=rows, searches=searches ) async def plot_trades(meta_database, plotted_element, historical_values=None): if historical_values: _, trades_data, _, _, _, _ = historical_values data = [] for trades in trades_data.values(): data += trades else: account_type = trading_api.get_account_type_from_run_metadata(await get_metadata(meta_database)) data = await meta_database.get_trades_db(account_type).all(commons_enums.DBTables.TRADES.value) key_to_label = { commons_enums.PlotAttributes.Y.value: "Price", commons_enums.PlotAttributes.TYPE.value: "Type", commons_enums.PlotAttributes.SIDE.value: "Side", } additional_columns = [ { "field": "total", "label": "Total", "render": None }, { "field": "fees", "label": "Fees", "render": None } ] def datum_columns_callback(datum): datum["total"] = datum["cost"] datum["fees"] = f'{datum["fees_amount"]} {datum["fees_currency"]}' _plot_table_data(data, plotted_element, commons_enums.DBTables.TRADES.value, key_to_label, additional_columns, datum_columns_callback) async def plot_orders(meta_database, plotted_element, historical_values=None): if historical_values: _, _, _, _, metadata, _ = historical_values else: metadata = await get_metadata(meta_database) account_type = trading_api.get_account_type_from_run_metadata(metadata) data = [ order[trading_constants.STORAGE_ORIGIN_VALUE] for order in await meta_database.get_orders_db(account_type).all(commons_enums.DBTables.ORDERS.value) ] key_to_label = { trading_enums.ExchangeConstantsOrderColumns.TIMESTAMP.value: "Time", trading_enums.ExchangeConstantsOrderColumns.PRICE.value: "Price", trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value: "Amount", trading_enums.ExchangeConstantsOrderColumns.TYPE.value: "Type", trading_enums.ExchangeConstantsOrderColumns.SIDE.value: "Side", } additional_columns = [ { "field": "total", "label": "Total", "render": None } ] def datum_columns_callback(datum): datum["total"] = datum[trading_enums.ExchangeConstantsOrderColumns.COST.value] datum[trading_enums.ExchangeConstantsOrderColumns.TIMESTAMP.value] *= 1000 _plot_table_data(data, plotted_element, commons_enums.DBTables.ORDERS.value, key_to_label, additional_columns, datum_columns_callback) async def plot_withdrawals(meta_database, plotted_element): withdrawal_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.BLOCKCHAIN_WITHDRAWAL.value,) ) # apply quantity to y for each withdrawal for withdrawal in withdrawal_history: withdrawal[commons_enums.PlotAttributes.Y.value] = withdrawal["quantity"] key_to_label = { commons_enums.PlotAttributes.Y.value: "Quantity", "currency": "Currency", commons_enums.PlotAttributes.SIDE.value: "Side", } additional_columns = [] _plot_table_data(withdrawal_history, plotted_element, "Withdrawals", key_to_label, additional_columns, None) async def plot_positions(meta_database, plotted_element): realized_pnl_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.CLOSE_REALISED_PNL.value,) ) key_to_label = { commons_enums.PlotAttributes.X.value: "Exit time", "first_entry_time": "Entry time", "average_entry_price": "Average entry price", "average_exit_price": "Average exit price", "cumulated_closed_quantity": "Cumulated closed quantity", "realised_pnl": "Realised PNL", commons_enums.PlotAttributes.SIDE.value: "Side", "trigger_source": "Closed by", } _plot_table_data(realized_pnl_history, plotted_element, "Positions", key_to_label, [], None) async def display(plotted_element, label, value): plotted_element.value(label, value) async def display_html(plotted_element, html): plotted_element.html_value(html) async def plot_table(meta_database, plotted_element, data_source, columns=None, rows=None, searches=None, column_render=None, types=None, cache_value=None): data = [] metadata = await get_metadata(meta_database) account_type = trading_api.get_account_type_from_run_metadata(metadata) if data_source == commons_enums.DBTables.TRADES.value: data = await meta_database.get_trades_db(account_type).all(commons_enums.DBTables.TRADES.value) elif data_source == commons_enums.DBTables.ORDERS.value: data = await meta_database.get_orders_db(account_type).all(commons_enums.DBTables.ORDERS.value) else: exchange = meta_database.run_dbs_identifier.context.exchange_name symbol = meta_database.run_dbs_identifier.context.symbol symbol_db = meta_database.get_symbol_db(exchange, symbol) if cache_value is None: data = await symbol_db.all(data_source) else: query = (await symbol_db.search()).title == data_source cache_data = await symbol_db.select(commons_enums.DBTables.CACHE_SOURCE.value, query) if cache_data: try: cache_database = databases.CacheDatabase(cache_data[0][commons_enums.PlotAttributes.VALUE.value]) cache = await cache_database.get_cache() x_shift = cache_data[0]["x_shift"] data = [ { commons_enums.PlotAttributes.X.value: (cache_element[commons_enums.CacheDatabaseColumns.TIMESTAMP.value] + x_shift) * 1000, commons_enums.PlotAttributes.Y.value: cache_element[cache_value] } for cache_element in cache ] except KeyError as e: get_logger().warning(f"Missing cache values when plotting data: {e}") except commons_errors.DatabaseNotFoundError as e: get_logger().warning(f"Missing cache values when plotting data: {e}") if not data: get_logger().debug(f"Nothing to create a table from when reading {data_source}") return column_render = column_render or _get_default_column_render() types = types or _get_default_types() columns = columns or _get_default_columns(plotted_element, data, column_render) rows = rows or _get_default_rows(data, columns) searches = searches or _get_default_searches(columns, types) plotted_element.table( data_source, columns=columns, rows=rows, searches=searches) def _get_default_column_render(): return { "Time": "datetime", "Entry time": "datetime", "Exit time": "datetime" } def _get_default_types(): return { "Time": "datetime", "Entry time": "datetime", "Exit time": "datetime" } def _get_default_columns(plotted_element, data, column_render, key_to_label=None): key_to_label = key_to_label or plotted_element.TABLE_KEY_TO_COLUMN return [ { "field": row_key, "label": key_to_label[row_key], "render": column_render.get(key_to_label[row_key], None) } for row_key, row_value in data[0].items() if row_key in key_to_label and row_value is not None ] def _get_default_rows(data, columns): column_fields = set(col["field"] for col in columns) return [ {key: val for key, val in row.items() if key in column_fields} for row in data ] def _get_default_searches(columns, types): return [ { "field": col["field"], "label": col["label"], "type": types.get(col["label"]) } for col in columns ] def _get_wins_and_losses_from_transactions(x_data, wins_and_losses_data, trading_transactions_history, x_as_trade_count): for transaction in trading_transactions_history: transaction_pnl = 0 if transaction["realised_pnl"] is None else transaction["realised_pnl"] current_cumulative_wins = wins_and_losses_data[-1] if wins_and_losses_data else 0 if transaction_pnl < 0: wins_and_losses_data.append(current_cumulative_wins - 1) elif transaction_pnl > 0: wins_and_losses_data.append(current_cumulative_wins + 1) else: continue if x_as_trade_count: x_data.append(len(wins_and_losses_data) - 1) else: x_data.append(transaction[commons_enums.PlotAttributes.X.value]) def _get_wins_and_losses_from_trades(x_data, wins_and_losses_data, trades_history, x_as_trade_count): # todo pass async def plot_historical_wins_and_losses(meta_database, plotted_element, exchange=None, x_as_trade_count=False, own_yaxis=True, historical_values=None): price_data, trades_data, _, _, _, _ = historical_values or await load_historical_values(meta_database, exchange) if not (price_data and next(iter(price_data.values()))): return x_data = [] wins_and_losses_data = [] trading_transactions_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.TRADING_FEE.value, trading_enums.TransactionType.FUNDING_FEE.value, trading_enums.TransactionType.REALISED_PNL.value, trading_enums.TransactionType.CLOSE_REALISED_PNL.value) ) if trading_transactions_history: # can rely on pnl history _get_wins_and_losses_from_transactions(x_data, wins_and_losses_data, trading_transactions_history, x_as_trade_count) else: # recreate pnl history from trades return # todo not implemented yet # _read_pnl_from_trades(x_data, pnl_data, cumulative_pnl_data, trades_data, x_as_trade_count) plotted_element.plot( mode="scatter", x=x_data, y=wins_and_losses_data, x_type="tick0" if x_as_trade_count else "date", title="wins and losses count", own_yaxis=own_yaxis, line_shape="hv") def _get_win_rates_from_transactions(x_data, win_rates_data, trading_transactions_history, x_as_trade_count): wins_count = 0 losses_count = 0 for transaction in trading_transactions_history: transaction_pnl = 0 if transaction["realised_pnl"] is None else transaction["realised_pnl"] if transaction_pnl < 0: losses_count += 1 elif transaction_pnl > 0: wins_count += 1 else: continue win_rates_data.append((wins_count/(losses_count+wins_count))*100) if x_as_trade_count: x_data.append(len(win_rates_data) - 1) else: x_data.append(transaction[commons_enums.PlotAttributes.X.value]) def _get_win_rates_from_trades(x_data, win_rates_data, trades_history, x_as_trade_count): # todo pass async def plot_historical_win_rates(meta_database, plotted_element, exchange=None, x_as_trade_count=False, own_yaxis=True, historical_values=None): price_data, trades_data, _, _, _, _ = historical_values or await load_historical_values(meta_database, exchange) if not (price_data and next(iter(price_data.values()))): return x_data = [] win_rates_data = [] trading_transactions_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.TRADING_FEE.value, trading_enums.TransactionType.FUNDING_FEE.value, trading_enums.TransactionType.REALISED_PNL.value, trading_enums.TransactionType.CLOSE_REALISED_PNL.value) ) if trading_transactions_history: # can rely on pnl history _get_win_rates_from_transactions(x_data, win_rates_data, trading_transactions_history, x_as_trade_count) else: # recreate pnl history from trades return # todo not implemented yet # _get_win_rates_from_trades(x_data, pnl_data, cumulative_pnl_data, trades_data, x_as_trade_count) plotted_element.plot( mode="scatter", x=x_data, y=win_rates_data, x_type="tick0" if x_as_trade_count else "date", title="win rate", own_yaxis=own_yaxis, line_shape="hv") async def _get_best_case_growth_from_transactions(trading_transactions_history, x_as_trade_count, meta_database): ref_market = meta_database.run_db._database.adaptor.database.storage.cache[commons_enums.DBTables.METADATA.value]['1']['ref_market'] start_balance = meta_database.run_db._database.adaptor.database.storage.cache[commons_enums.DBTables.PORTFOLIO.value]['1'][ref_market]['total'] best_case_data, _, start_balance, end_balance, x_data \ = await portfolio_util.get_coefficient_of_determination_data(transactions=trading_transactions_history, start_balance=start_balance, use_high_instead_of_end_balance=True, x_as_trade_count=x_as_trade_count) if best_case_data: return x_data, best_case_data return [], [] async def plot_best_case_growth(meta_database, plotted_element, exchange=None, x_as_trade_count=False, own_yaxis=False, historical_values=None): price_data, trades_data, _, _, _, _ = historical_values or await load_historical_values(meta_database, exchange) if not (price_data and next(iter(price_data.values()))): return x_data = [] best_case_data = [] trading_transactions_history = await get_transactions( meta_database, transaction_types=(trading_enums.TransactionType.TRADING_FEE.value, trading_enums.TransactionType.FUNDING_FEE.value, trading_enums.TransactionType.REALISED_PNL.value, trading_enums.TransactionType.CLOSE_REALISED_PNL.value) ) if trading_transactions_history: # can rely on pnl history x_data, best_case_data = await _get_best_case_growth_from_transactions(trading_transactions_history, x_as_trade_count, meta_database) plotted_element.plot( mode="scatter", x=x_data, y=best_case_data, x_type="tick0" if x_as_trade_count else "date", title="best case growth", own_yaxis=own_yaxis, line_shape="hv") ================================================ FILE: Meta/Keywords/scripting_library/configuration/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Meta.Keywords.scripting_library.configuration.profile_data_configuration import * from tentacles.Meta.Keywords.scripting_library.configuration.tentacles_configuration import * from tentacles.Meta.Keywords.scripting_library.configuration.indexes_configuration import * from tentacles.Meta.Keywords.scripting_library.configuration.exchanges_configuration import * ================================================ FILE: Meta/Keywords/scripting_library/configuration/exchanges_configuration.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as constants # TODO later: find a way to store this in exchange tentacles instead and use exchange.get_default_reference_market # Issue: hollaex based exchanages require an exchange configuration to be identified as such _SPECIFIC_REFERENCE_MARKET_PER_EXCHANGE: dict[str, str] = { "coinbase": "USDC", "binance": "USDC", } _EXCHANGES_WITH_DIFFERENT_PUBLIC_DATA_AFTER_AUTH = set[str]([ "mexc", "lbank", ]) def get_default_reference_market_per_exchange(exchanges: list[str]) -> dict[str, str]: return {exchange: get_default_exchange_reference_market(exchange) for exchange in exchanges} def get_default_exchange_reference_market(exchange: str) -> str: return _SPECIFIC_REFERENCE_MARKET_PER_EXCHANGE.get(exchange, constants.DEFAULT_REFERENCE_MARKET) def is_exchange_with_different_public_data_after_auth(exchange: str) -> bool: return exchange in _EXCHANGES_WITH_DIFFERENT_PUBLIC_DATA_AFTER_AUTH ================================================ FILE: Meta/Keywords/scripting_library/configuration/indexes_configuration.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_commons import octobot_commons.constants as common_constants import octobot_commons.enums as common_enums import octobot_commons.profiles as commons_profiles import octobot_commons.profiles.profile_data as commons_profile_data import octobot_commons.symbols import octobot_evaluators.constants as evaluators_constants import octobot_trading.constants as trading_constants import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tentacles.Meta.Keywords.scripting_library.configuration.exchanges_configuration as exchanges_configuration def create_index_config_from_tentacles_config( tentacles_config: list[commons_profile_data.TentaclesData], exchange: str, starting_funds: float, backtesting_start_time_delta: float ) -> commons_profiles.ProfileData: trading_mode_config = tentacles_config[0].config distribution = trading_mode_config[index_trading.IndexTradingModeProducer.INDEX_CONTENT] reference_market = exchanges_configuration.get_default_exchange_reference_market(exchange) # replace USD by reference market for element in distribution: if element[index_distribution.DISTRIBUTION_NAME] == "USD": element[index_distribution.DISTRIBUTION_NAME] = reference_market coins_by_symbol = { element[index_distribution.DISTRIBUTION_NAME]: element[index_distribution.DISTRIBUTION_NAME] for element in distribution } rebalance_cap = trading_mode_config[index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] min_funds = starting_funds / 10 selected_rebalance_trigger_profile = trading_mode_config.get(index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE, None) rebalance_trigger_profiles = trading_mode_config.get(index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, None) profile_data_dict = generate_index_config( distribution, rebalance_cap, selected_rebalance_trigger_profile, rebalance_trigger_profiles, reference_market, exchange, min_funds, coins_by_symbol, False, backtesting_start_time_delta ) return commons_profiles.ProfileData.from_dict(profile_data_dict) def generate_index_config( distribution: typing.List, rebalance_cap: float, selected_rebalance_trigger_profile: typing.Optional[str], rebalance_trigger_profiles: typing.Optional[list[dict]], reference_market: str, exchange: str, min_funds: float, coins_by_symbol: dict[str, str], disabled_backtesting: bool, backtesting_start_time_delta: float ) -> dict: profile_details = commons_profile_data.ProfileDetailsData(name="serverless") trading = commons_profile_data.TradingData( reference_market=reference_market, risk=0.5 ) config_exchanges = [commons_profile_data.ExchangeData( internal_name=exchange, exchange_type=common_constants.CONFIG_EXCHANGE_SPOT )] currencies = [ commons_profile_data.CryptoCurrencyData( [octobot_commons.symbols.merge_currencies(element[index_distribution.DISTRIBUTION_NAME], reference_market)], coins_by_symbol.get( element[index_distribution.DISTRIBUTION_NAME], element[index_distribution.DISTRIBUTION_NAME] ) ) for element in distribution if element[index_distribution.DISTRIBUTION_NAME] != reference_market ] trader = commons_profile_data.TraderData(enabled=True) trader_simulator = commons_profile_data.TraderSimulatorData() tentacles = [ commons_profile_data.TentaclesData( index_trading.IndexTradingMode.get_name(), _get_index_trading_config( distribution, rebalance_cap, selected_rebalance_trigger_profile, rebalance_trigger_profiles ) ) ] backtesting = generate_index_backtesting_config( exchange, reference_market, min_funds, disabled_backtesting, backtesting_start_time_delta ) base_config = commons_profiles.ProfileData( profile_details, currencies, trading, config_exchanges, commons_profile_data.FutureExchangeData(), trader, trader_simulator, tentacles, backtesting ) return base_config.to_dict(include_default_values=False) def generate_index_backtesting_config( exchange: str, reference_market: str, min_funds: float, disabled_backtesting: bool, start_time_delta: float ) -> commons_profile_data.BacktestingContext: return commons_profile_data.BacktestingContext( exchanges=[] if disabled_backtesting else [exchange], start_time_delta=start_time_delta, starting_portfolio={ reference_market: min_funds * 10 # make sure there is always enough funds even if the market crashes } ) def _get_index_trading_config( distribution: typing.List, rebalance_cap: float, selected_rebalance_trigger_profile: typing.Optional[str], rebalance_trigger_profiles: typing.Optional[list[dict]] ): return { trading_constants.TRADING_MODE_REQUIRED_STRATEGIES: [], index_trading.IndexTradingModeProducer.REFRESH_INTERVAL: 1, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: rebalance_cap, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: selected_rebalance_trigger_profile, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: rebalance_trigger_profiles, index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value, index_trading.IndexTradingModeProducer.SELL_UNINDEXED_TRADED_COINS: True, index_trading.IndexTradingModeProducer.INDEX_CONTENT: distribution, evaluators_constants.STRATEGIES_REQUIRED_TIME_FRAME: [common_enums.TimeFrames.ONE_DAY.value], } ================================================ FILE: Meta/Keywords/scripting_library/configuration/profile_data_configuration.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import logging import typing import os import sortedcontainers import time import octobot_commons import octobot_commons.constants as common_constants import octobot_commons.enums as common_enums import octobot_commons.configuration as commons_configuration import octobot_commons.profiles as commons_profiles import octobot_commons.profiles.profile_data as commons_profile_data import octobot_commons.tentacles_management as tentacles_management import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.symbols import octobot_commons.logging import octobot_evaluators.constants as evaluators_constants import octobot_trading.constants as trading_constants import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.exchange_data as exchange_data_import import octobot_trading.api import octobot_tentacles_manager.api import octobot_tentacles_manager.configuration import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tentacles.Meta.Keywords.scripting_library.errors as scr_errors import tentacles.Meta.Keywords.scripting_library.constants as scr_constants import tentacles.Meta.Keywords.scripting_library.configuration.tentacles_configuration as tentacles_configuration import tentacles.Meta.Keywords.scripting_library.configuration.indexes_configuration as indexes_configuration _AUTH_REQUIRED_EXCHANGES: dict[str, bool] = {} def minimal_profile_data() -> commons_profiles.ProfileData: return commons_profiles.ProfileData.from_dict({ "profile_details": {"name": ""}, "crypto_currencies": [], "exchanges": [], "trading": {"reference_market": common_constants.DEFAULT_REFERENCE_MARKET} }) def empty_config_proxy(*_, **__): return {} def create_backtesting_config( profile_data: commons_profiles.ProfileData, exchange_data: exchange_data_import.ExchangeData, ) -> commons_configuration.Configuration: tentacles_config = get_full_tentacles_setup_config(profile_data) apply_leverage_config(profile_data) profile_data.exchanges = [] # clear exchange to avoid conflicts with backtesting exchanges return get_config( profile_data, exchange_data, tentacles_config, False, False, False ) def get_config( profile_data: commons_profiles.ProfileData, exchange_data: exchange_data_import.ExchangeData, tentacles_setup_config, auth: bool, ignore_symbols_in_exchange_init: bool, use_exchange_data_portfolio: bool, ) -> commons_configuration.Configuration: config = commons_configuration.Configuration(None, None) config.logger.logger.setLevel(logging.WARNING) # disable "using XYZ profile." log config.config = {} initial_backtesting_context = profile_data.backtesting_context # always use exchange data on real trading # use exchange data on simulated only when exchange_data.portfolio_details.content is available if use_exchange_data_portfolio and ( not profile_data.trader_simulator.enabled or exchange_data.portfolio_details.content ): _set_portfolio(profile_data, exchange_data.portfolio_details.content) # do not allow using backtesting context when using exchange data portfolio profile_data.backtesting_context = None # type: ignore profile = profile_data.to_profile(None) profile_data.backtesting_context = initial_backtesting_context config.profile_by_id[profile.profile_id] = profile config.select_profile(profile.profile_id) config.config[common_constants.CONFIG_EXCHANGES][exchange_data.exchange_details.name] = get_exchange_config( exchange_data, tentacles_setup_config, get_config_by_tentacle(profile_data), auth ) if ignore_symbols_in_exchange_init: config.config[common_constants.CONFIG_CRYPTO_CURRENCIES] = {} config.config[common_constants.CONFIG_TIME_FRAME] = time_frame_manager.sort_time_frames(list(set( common_enums.TimeFrames(market.time_frame) for market in exchange_data.markets ))) return config def get_exchange_config( exchange_data: exchange_data_import.ExchangeData, tentacles_setup_config, exchange_config_by_exchange: typing.Optional[dict[str, dict]], auth: bool ): auth_details = exchange_data.auth_details if not auth: always_auth = is_auth_required_exchanges(exchange_data, tentacles_setup_config, exchange_config_by_exchange) if always_auth: auth_details = get_readonly_exchange_auth_details(exchange_data.exchange_details.name) auth = True exchange_config = { common_constants.CONFIG_EXCHANGE_KEY: auth_details.api_key if auth else None, common_constants.CONFIG_EXCHANGE_SECRET: auth_details.api_secret if auth else None, common_constants.CONFIG_EXCHANGE_PASSWORD: auth_details.api_password if auth else None, common_constants.CONFIG_EXCHANGE_ACCESS_TOKEN: auth_details.access_token if auth else None, common_constants.CONFIG_EXCHANGE_TYPE: auth_details.exchange_type or common_constants.CONFIG_EXCHANGE_SPOT, } exchange_config[common_constants.CONFIG_EXCHANGE_SANDBOXED] = auth_details.sandboxed return exchange_config def create_profile_data_from_tentacles_config_history( tentacles_config_by_time: dict[float, list[commons_profile_data.TentaclesData]], exchange: str, starting_funds: float ) -> commons_profiles.ProfileData: if not tentacles_config_by_time: raise ValueError("tentacles_config_by_time is empty") ordered_config = sortedcontainers.SortedDict(tentacles_config_by_time) first_config = next(iter(ordered_config.values())) if first_config[0].name == index_trading.IndexTradingMode.get_name(): backtesting_start_time_delta = time.time() - next(iter(ordered_config)) historical_config_by_time = { timestamp: indexes_configuration.create_index_config_from_tentacles_config( config, exchange, starting_funds, backtesting_start_time_delta ) for timestamp, config in ordered_config.items() } master_config = next(iter(historical_config_by_time.values())) if len(historical_config_by_time) > 1: register_historical_configs( master_config, historical_config_by_time, add_historical_trading_pairs_to_master_profile_data=True, apply_master_tentacle_config_edits_to_historical_configs=False ) return master_config else: # todo implement other trading modes if necessary raise ValueError(f"{first_config.name} config not implemented") def register_historical_configs( master_profile_data: commons_profiles.ProfileData, historical_profile_data_by_time: dict[float, commons_profiles.ProfileData], add_historical_trading_pairs_to_master_profile_data: bool, apply_master_tentacle_config_edits_to_historical_configs: bool ): if add_historical_trading_pairs_to_master_profile_data: # 1. register every historical profile traded pairs in master profile if added_pairs := get_historical_added_config_trading_pairs( master_profile_data, historical_profile_data_by_time.values() ): add_traded_symbols(master_profile_data, added_pairs) # 2. register historical tentacles_config config_by_tentacle = get_config_by_tentacle(master_profile_data) for historical_time, historical_profile in historical_profile_data_by_time.items(): historical_config_by_tentacle = get_config_by_tentacle(historical_profile) for tentacle, config in historical_config_by_tentacle.items(): master_config = config_by_tentacle[tentacle] if config is not master_config: if apply_master_tentacle_config_edits_to_historical_configs: try: _apply_master_tentacle_config_edits_to_historical_config(tentacle, master_config, config) except RuntimeError: # tentacle not found, continue _get_logger().error(f"Tentacle {tentacle} not found in available tentacles") commons_configuration.add_historical_tentacle_config( master_config, historical_time, config, ) def _apply_master_tentacle_config_edits_to_historical_config(tentacle: str, master_config: dict, historical_config: dict): if updatable_keys := tentacles_configuration.get_config_history_propagated_tentacles_config_keys(tentacle): for key in updatable_keys: if key in master_config: historical_config[key] = master_config[key] def get_historical_added_config_trading_pairs( master_profile_data: commons_profiles.ProfileData, historical_profile_data: typing.Optional[typing.Iterable[commons_profiles.ProfileData]] ) -> list[str]: if historical_profile_data: historical_pairs = [ pair for historical_profile in historical_profile_data for pair in get_traded_symbols(historical_profile) ] else: historical_pairs = get_historical_traded_pairs(master_profile_data) registered_pairs = get_traded_symbols(master_profile_data) added_pairs = [] for pair in historical_pairs: if pair not in registered_pairs: registered_pairs.append(pair) added_pairs.append(pair) return added_pairs def get_historical_traded_pairs( profile_data: commons_profiles.ProfileData ) -> typing.Iterable[str]: trading_mode = get_trading_mode(profile_data) trading_mode_config = _get_trading_mode_config(profile_data) historical_trading_mode_configs = commons_configuration.get_historical_tentacle_configs( trading_mode_config, 0, time.time() ) if trading_mode == index_trading.IndexTradingMode.get_name(): return _get_historical_index_trading_pairs(profile_data, historical_trading_mode_configs) #todo else: raise NotImplementedError(f"Trading mode {trading_mode} not implemented") def _get_historical_index_trading_pairs( profile_data: commons_profiles.ProfileData, historical_trading_mode_configs: typing.Iterable[dict] ) -> typing.Iterable[str]: historical_assets = [] latest_config_assets = set( asset[index_distribution.DISTRIBUTION_NAME] for asset in _get_trading_mode_config(profile_data)[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] ) for historical_trading_mode_config in historical_trading_mode_configs: for asset in historical_trading_mode_config[index_trading.IndexTradingModeProducer.INDEX_CONTENT]: historical_asset = asset[index_distribution.DISTRIBUTION_NAME] if historical_asset not in historical_assets and historical_asset not in latest_config_assets: historical_assets.append(historical_asset) return [ octobot_commons.symbols.merge_currencies(asset, profile_data.trading.reference_market) for asset in historical_assets ] def add_traded_symbols( profile_data: commons_profiles.ProfileData, added_symbols: typing.Iterable[str] ): traded_symbols = get_traded_symbols(profile_data) to_add_symbols = [ symbol for symbol in added_symbols if symbol not in traded_symbols ] if to_add_symbols: _get_logger().info(f"Adding {to_add_symbols} to profile data traded pairs.") expand_traded_pairs_into_currencies(profile_data, to_add_symbols) def expand_traded_pairs_into_currencies(profile_data, pairs: list[str]): for pair in pairs: profile_data.crypto_currencies.append( commons_profile_data.CryptoCurrencyData( trading_pairs=[pair], name=pair, enabled=True ) ) def filter_out_missing_symbols(profile_data: commons_profiles.ProfileData, available_symbols: list[str]) -> list[str]: traded_pairs = get_traded_symbols(profile_data) removed_symbols = [symbol for symbol in traded_pairs if symbol not in available_symbols] if removed_symbols: profile_data.crypto_currencies = [] add_traded_symbols( profile_data, [pair for pair in traded_pairs if pair not in removed_symbols] ) return removed_symbols def get_readonly_exchange_auth_details(exchange_internal_name: str) -> exchange_data_import.ExchangeAuthDetails: return exchange_data_import.ExchangeAuthDetails( api_key=_get_readonly_exchange_credential_from_env(exchange_internal_name, "KEY", False), api_secret=_get_readonly_exchange_credential_from_env(exchange_internal_name, "SECRET", False), api_password=_get_readonly_exchange_credential_from_env(exchange_internal_name, "PASSWORD", True), sandboxed=False, broker_enabled=False, ) def _get_readonly_exchange_credential_from_env(exchange_name, cred_suffix, allow_missing): # for coinbase: COINBASE_READ_ONLY_KEY, COINBASE_READ_ONLY_SECRET, COINBASE_READ_PASSWORD if cred := os.getenv(f"{exchange_name}_READ_ONLY_{cred_suffix}".upper(), None): return commons_configuration.encrypt(cred).decode() if allow_missing: return None raise scr_errors.MissingReadOnlyExchangeCredentialsError( f"{exchange_name} read only credentials are missing" ) def is_auth_required_exchanges( exchange_data: exchange_data_import.ExchangeData, tentacles_setup_config, exchange_config_by_exchange: typing.Optional[dict[str, dict]] ): try: if exchange_config_by_exchange and any( exchange_config.get(common_constants.CONFIG_FORCE_AUTHENTICATION, False) for exchange_config in exchange_config_by_exchange.values() ): # don't use cache when force authentication is True: this can be specific to this context return _get_is_auth_required_exchange( exchange_data, tentacles_setup_config, exchange_config_by_exchange ) # use cache to avoid using introspection each time return _AUTH_REQUIRED_EXCHANGES[exchange_data.exchange_details.name] except KeyError: _AUTH_REQUIRED_EXCHANGES[exchange_data.exchange_details.name] = _get_is_auth_required_exchange( exchange_data, tentacles_setup_config, exchange_config_by_exchange ) return _AUTH_REQUIRED_EXCHANGES[exchange_data.exchange_details.name] def _get_is_auth_required_exchange( exchange_data: exchange_data_import.ExchangeData, tentacles_setup_config, exchange_config_by_exchange: typing.Optional[dict[str, dict]] ): exchange_class = exchanges.get_rest_exchange_class( exchange_data.exchange_details.name, tentacles_setup_config, exchange_config_by_exchange ) return exchange_class.requires_authentication( None, tentacles_setup_config, exchange_config_by_exchange ) def _set_portfolio( profile_data: commons_profiles.ProfileData, portfolio: dict ): profile_data.trader_simulator.starting_portfolio = get_formatted_portfolio(portfolio) def get_formatted_portfolio(portfolio: dict): for asset in portfolio.values(): if common_constants.PORTFOLIO_AVAILABLE not in asset: asset[common_constants.PORTFOLIO_AVAILABLE] = asset[trading_constants.CONFIG_PORTFOLIO_FREE] return portfolio def get_config_by_tentacle(profile_data: commons_profiles.ProfileData) -> dict[str, dict]: return { tentacle.name: tentacle.config for tentacle in profile_data.tentacles } def get_full_tentacles_setup_config( profile_data: commons_profiles.ProfileData = None, ensure_tentacle_info: bool = True, extra_tentacle_names: list = None ) -> octobot_tentacles_manager.configuration.TentaclesSetupConfiguration: if ensure_tentacle_info: octobot_tentacles_manager.api.ensure_tentacle_info() classes = [ tentacle_class.__name__ for tentacle_class in tentacles_configuration.get_all_exchange_tentacles() if not (tentacle_class.is_default_exchange() or tentacle_class.__name__ == exchanges.ExchangeSimulator.__name__) ] if profile_data: try: classes.extend( # always use tentacle class names here as tentacles are indexed by name tentacle_data.name if extra_tentacle_names and tentacle_data.name in extra_tentacle_names else octobot_tentacles_manager.api.get_tentacle_class_from_string(tentacle_data.name).__name__ for tentacle_data in profile_data.tentacles ) except RuntimeError as err: raise scr_errors.InvalidTentacleProfileError(err) from err if extra_tentacle_names: classes.extend(extra_tentacle_names) return octobot_tentacles_manager.api.create_tentacles_setup_config_with_tentacles(*classes) def merge_profile_data( profile_data: commons_profiles.ProfileData, previous_profile_data: commons_profiles.ProfileData, ) -> commons_profiles.ProfileData: # previous config crypto currencies are merged current_traded_pairs = set(get_traded_symbols(profile_data)) for currency_data in previous_profile_data.crypto_currencies: for previous_traded_pair in currency_data.trading_pairs: to_add_pairs = set() if previous_traded_pair not in current_traded_pairs: # add pair to_add_pairs.add(previous_traded_pair) parsed_symbol = octobot_commons.symbols.parse_symbol(previous_traded_pair) if parsed_symbol.quote != profile_data.trading.reference_market: # reference market changed: also include the base of this pair within the traded pairs ref_market_pair = octobot_commons.symbols.merge_currencies( parsed_symbol.base, profile_data.trading.reference_market ) if ref_market_pair not in current_traded_pairs: to_add_pairs.add(ref_market_pair) for traded_pair in to_add_pairs: _get_logger().info( f"Profile data merge: including previous config {currency_data} currency into current profile data" ) expand_traded_pairs_into_currencies(profile_data, [traded_pair]) current_traded_pairs.add(traded_pair) return profile_data def apply_leverage_config(profile_data: commons_profiles.ProfileData): if leverage := profile_data.future_exchange_data.default_leverage: trading_mode_config = _get_trading_mode_config(profile_data) apply_leverage_config_to_trading_mode_config_if_necessary(trading_mode_config, leverage) def apply_leverage_config_to_trading_mode_config_if_necessary(trading_mode_config: dict, leverage: float): if trading_constants.CONFIG_LEVERAGE not in trading_mode_config: trading_mode_config[trading_constants.CONFIG_LEVERAGE] = leverage def _get_trading_mode_config(profile_data: commons_profiles.ProfileData): trading_mode = get_trading_mode(profile_data) config_by_tentacle = get_config_by_tentacle(profile_data) if trading_mode in config_by_tentacle: return config_by_tentacle[trading_mode] raise KeyError(f"No trading mode config found in {list(config_by_tentacle)} tentacles config") def get_trading_mode(profile_data: commons_profiles.ProfileData) -> typing.Optional[str]: for tentacle_name in get_config_by_tentacle(profile_data): if tentacles_configuration.is_trading_mode_tentacle(tentacle_name): return tentacle_name return None def get_traded_symbols( profile_data: commons_profiles.ProfileData ) -> list[str]: symbols = [] for crypto_currency in profile_data.crypto_currencies: symbols.extend(crypto_currency.trading_pairs) return symbols def get_traded_coins( profile_data: commons_profiles.ProfileData, include_stablecoins: bool, ) -> list[str]: # return an ordered list of: # 1. reference market # 2. traded assets # 3. stablecoins if include_stablecoins is True coins = [profile_data.trading.reference_market, ] for symbol in get_traded_symbols(profile_data): base, quote = octobot_commons.symbols.parse_symbol(symbol).base_and_quote() if base not in coins: coins.append(base) if quote not in coins: coins.append(quote) if include_stablecoins: coins.extend(tuple( coin for coin in common_constants.USD_LIKE_AND_FIAT_COINS if coin not in coins )) return coins def get_time_frames( profile_data: commons_profiles.ProfileData, for_historical_data=False ): for config in get_config_by_tentacle(profile_data).values(): if evaluators_constants.STRATEGIES_REQUIRED_TIME_FRAME in config: return config[evaluators_constants.STRATEGIES_REQUIRED_TIME_FRAME] return [_get_default_time_frame(profile_data, for_historical_data)] def _get_default_time_frame(profile_data: commons_profiles.ProfileData, for_historical_data: bool): if not for_historical_data: # always use DEFAULT_TIMEFRAME when focusing on historical data return scr_constants.DEFAULT_TIMEFRAME.value return _get_historical_default_time_frame(profile_data) def _get_historical_default_time_frame(profile_data: commons_profiles.ProfileData): if time_frame := get_default_historical_time_frame(profile_data): return time_frame.value # fallback to default timeframe return scr_constants.DEFAULT_TIMEFRAME.value def requires_price_update_timeframe(profile_data: commons_profiles.ProfileData) -> bool: if trading_mode := get_trading_mode(profile_data): return octobot_tentacles_manager.api.get_tentacle_class_from_string( trading_mode ).use_backtesting_accurate_price_update() return True def get_default_historical_time_frame(profile_data: commons_profiles.ProfileData) -> typing.Optional[common_enums.TimeFrames]: if trading_mode := get_trading_mode(profile_data): return octobot_tentacles_manager.api.get_tentacle_class_from_string( trading_mode ).get_default_historical_time_frame() return None def can_convert_ref_market_to_usd_like( exchange_data: exchange_data_import.ExchangeData, profile_data: commons_profiles.ProfileData ): return can_convert_ref_market_to_usd_like_from_symbols( profile_data.trading.reference_market, [market.symbol for market in exchange_data.markets] ) def can_convert_ref_market_to_usd_like_from_symbols( reference_market: str, symbols: list[str] ): if octobot_trading.api.is_usd_like_coin(reference_market): return True for symbol in symbols: if ( reference_market in octobot_commons.symbols.parse_symbol(symbol).base_and_quote() and octobot_trading.api.can_convert_symbol_to_usd_like(symbol) ): return True return False def set_backtesting_portfolio(profile_data, exchange_data): exchange_data.portfolio_details.content = { asset: { common_constants.PORTFOLIO_AVAILABLE: value, common_constants.PORTFOLIO_TOTAL: value } for asset, value in profile_data.backtesting_context.starting_portfolio.items() } _get_logger().info( f"Applied {profile_data.profile_details.name} backtesting starting " f"portfolio: {profile_data.backtesting_context.starting_portfolio}" ) def get_oldest_historical_config_symbols_and_time(profile_data: commons_profiles.ProfileData, default) -> (list, float): first_historical_config_time = _get_first_historical_config_time(profile_data, default) if first_historical_config_time == default: base_traded_symbols = get_traded_symbols(profile_data) return base_traded_symbols, base_traded_symbols, default first_traded_symbols = _get_all_tentacles_configured_traded_symbols(profile_data, first_historical_config_time) last_traded_symbols = _get_all_tentacles_configured_traded_symbols(profile_data, None) return list(first_traded_symbols), list(last_traded_symbols), first_historical_config_time def _get_all_tentacles_configured_traded_symbols( profile_data: commons_profiles.ProfileData, first_historical_config_time: typing.Optional[float] ) -> set: traded_symbols = set() tentacles_config = get_config_by_tentacle(profile_data) for tentacle, tentacle_config in tentacles_config.items(): if first_historical_config_time is None: config = tentacle_config else: try: config = commons_configuration.get_historical_tentacle_config( tentacle_config, first_historical_config_time ) except KeyError as err: if tentacles_configuration.is_exchange_tentacle(tentacle): # exchange tentacles (like HollaEx exchanges) don't have historical configuration: this is normal pass else: raise scr_errors.InvalidProfileError(f"{tentacle} tentacle config is invalid: {err}") traded_symbols.update(get_tentacle_config_traded_symbols( tentacle, config, profile_data.trading.reference_market )) return traded_symbols def _get_first_historical_config_time(profile_data: commons_profiles.ProfileData, default) -> float: tentacles_config = get_config_by_tentacle(profile_data) oldest_config_times = [] for tentacle, config in tentacles_config.items(): try: oldest_config_times.append( commons_configuration.get_oldest_historical_tentacle_config_time( config ) ) except ValueError: # no historical config pass if oldest_config_times: # return the most recent of the oldest configurations return max(oldest_config_times) return default def get_tentacle_config_traded_symbols(tentacle: str, config: dict, reference_market: str) -> set: tentacle_class = octobot_tentacles_manager.api.get_tentacle_class_from_string(tentacle) try: return set(tentacle_class.get_tentacle_config_traded_symbols(config, reference_market)) except NotImplementedError as err: if tentacles_configuration.is_exchange_tentacle(tentacle): # exchange tentacles don't implement get_tentacle_config_traded_symbols, this is normal pass else: _get_logger().warning( f"Trying to get tentacle config historical traded symbols for {tentacle}: {err}" ) return set() def _get_logger(): return octobot_commons.logging.get_logger("ScriptedProfileData") ================================================ FILE: Meta/Keywords/scripting_library/configuration/tentacles_configuration.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import functools import octobot_commons.tentacles_management as tentacles_management import octobot_trading.exchanges as exchanges import octobot_trading.modes import octobot_tentacles_manager.api _EXPECTED_MAX_TENTACLES_COUNT = 256 def get_config_history_propagated_tentacles_config_keys(tentacle: str) -> list[str]: tentacle_class = octobot_tentacles_manager.api.get_tentacle_class_from_string(tentacle) return tentacle_class.get_config_history_propagated_tentacles_config_keys() # cached to avoid calling default_parents_inspection when unnecessary @functools.lru_cache(maxsize=_EXPECTED_MAX_TENTACLES_COUNT) def is_trading_mode_tentacle(tentacle_name: str) -> bool: tentacle_class = octobot_tentacles_manager.api.get_tentacle_class_from_string(tentacle_name) return tentacles_management.default_parents_inspection(tentacle_class, octobot_trading.modes.AbstractTradingMode) # cached to avoid calling default_parents_inspection when unnecessary @functools.lru_cache(maxsize=_EXPECTED_MAX_TENTACLES_COUNT) def is_exchange_tentacle(tentacle_name: str) -> bool: tentacle_class = octobot_tentacles_manager.api.get_tentacle_class_from_string(tentacle_name) return tentacles_management.default_parents_inspection(tentacle_class, exchanges.RestExchange) # cached to avoid calling default_parents_inspection when unnecessary @functools.lru_cache(maxsize=2) def get_all_exchange_tentacles() -> list[type[exchanges.RestExchange]]: return tentacles_management.get_all_classes_from_parent(exchanges.RestExchange) def get_exchange_tentacle_from_name(tentacle_name: str) -> type[exchanges.RestExchange]: for exchange_tentacle in get_all_exchange_tentacles(): if exchange_tentacle.get_name() == tentacle_name: return exchange_tentacle raise ValueError(f"No exchange tentacle found for name: {tentacle_name}") ================================================ FILE: Meta/Keywords/scripting_library/constants.py ================================================ import octobot_commons.enums as common_enums DEFAULT_TIMEFRAME = common_enums.TimeFrames.ONE_HOUR PRICE_UPDATE_TIME_FRAME = common_enums.TimeFrames.FIFTEEN_MINUTES ================================================ FILE: Meta/Keywords/scripting_library/data/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .reading import * from .writing import * ================================================ FILE: Meta/Keywords/scripting_library/data/reading/__init__.py ================================================ from .exchange_public_data import * from .exchange_private_data import * from .metadata_reader import * from .trading_settings import * ================================================ FILE: Meta/Keywords/scripting_library/data/reading/exchange_private_data/__init__.py ================================================ from .open_positions import * ================================================ FILE: Meta/Keywords/scripting_library/data/reading/exchange_private_data/open_positions.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.constants as commons_constants import octobot_trading.modes.script_keywords as script_keywords import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums #todo clear def is_current_contract_inverse(context, symbol=None, side=trading_enums.PositionSide.BOTH.value): return script_keywords.get_position(context, symbol=symbol, side=side).symbol_contract.is_inverse_contract() # returns negative values when in a short position def open_position_size( context, side=trading_enums.PositionSide.BOTH.value, symbol=None, amount_type=commons_constants.PORTFOLIO_TOTAL ): symbol = symbol or context.symbol if context.exchange_manager.is_future: return script_keywords.get_position(context, symbol, side).size currency = symbol_util.parse_symbol(context.symbol).base portfolio = context.exchange_manager.exchange_personal_data.portfolio_manager.portfolio return portfolio.get_currency_portfolio(currency).total if amount_type == commons_constants.PORTFOLIO_TOTAL \ else portfolio.get_currency_portfolio(currency).available # todo handle reference market change # todo handle futures: its account balance from exchange # todo handle futures and return negative for shorts def is_position_open( context, side=None ): if side is None: long_open = open_position_size(context, side="long") != trading_constants.ZERO short_open = open_position_size(context, side="short") != trading_constants.ZERO return True if long_open or short_open else False else: return open_position_size(context, side=side) != trading_constants.ZERO def is_position_long( context, ): return script_keywords.get_position(context).is_long() def is_position_short( context, ): return script_keywords.get_position(context).is_short() ================================================ FILE: Meta/Keywords/scripting_library/data/reading/exchange_public_data.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_trading.api as api import octobot_trading.constants as trading_constants import octobot_trading.exchange_data import octobot_trading.personal_data as personal_data import octobot_trading.exchange_data as exchange_data import octobot_trading.enums as trading_enums import octobot_backtesting.api as backtesting_api from octobot_trading.modes.script_keywords.basic_keywords import run_persistence as run_persistence from tentacles.Evaluator.Util.candles_util import CandlesUtil # real time in live mode # lowest available candle time on backtesting def current_live_time(context) -> float: return api.get_exchange_current_time(context.exchange_manager) def symbol_fees(context, symbol=None) -> dict: return context.exchange_manager.exchange.get_fees(symbol or context.symbol) def is_futures_trading(context) -> bool: return context.exchange_manager.is_future def _time_frame_to_sec(context, time_frame=None): return commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(time_frame or context.time_frame)] * \ commons_constants.MINUTE_TO_SECONDS async def current_candle_time(context, symbol=None, time_frame=None, use_close_time=False): symbol = symbol or context.symbol time_frame = time_frame or context.time_frame candles_manager = api.get_symbol_candles_manager( api.get_symbol_data(context.exchange_manager, symbol, allow_creation=False), time_frame ) if use_close_time: return candles_manager.time_candles[candles_manager.time_candles_index - 1] + \ _time_frame_to_sec(context, time_frame) return candles_manager.time_candles[candles_manager.time_candles_index - 1] async def current_closed_candle_time(context, symbol=None, time_frame=None): return await current_candle_time(context, symbol=symbol, time_frame=time_frame) \ - _time_frame_to_sec(context, time_frame) # Use capital letters to avoid python native lib conflicts async def Time(context, symbol=None, time_frame=None, limit=-1, max_history=False, use_close_time=True): candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) if max_history and isinstance(candles_manager, octobot_trading.exchange_data.PreloadedCandlesManager): time_data = candles_manager.time_candles else: time_data = candles_manager.get_symbol_time_candles(-1 if max_history else limit) if use_close_time: return [value + _time_frame_to_sec(context, time_frame) for value in time_data] return time_data # real time in live mode # lowest available candle closes on backtesting async def current_live_price(context, symbol=None): return await personal_data.get_up_to_date_price(context.exchange_manager, symbol or context.symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT, base_error="Can't get the current price:") async def current_candle_price(context, symbol=None, time_frame=None): candles_manager = await _get_candle_manager(context, symbol, time_frame, False) return candles_manager.get_symbol_close_candles(1)[-1] # Use capital letters to avoid python native lib conflicts async def Open(context, symbol=None, time_frame=None, limit=-1, max_history=False): candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) if isinstance(candles_manager, octobot_trading.exchange_data.PreloadedCandlesManager) and max_history: return candles_manager.open_candles return candles_manager.get_symbol_open_candles(-1 if max_history else limit) # Use capital letters to avoid python native lib conflicts async def High(context, symbol=None, time_frame=None, limit=-1, max_history=False): candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) if isinstance(candles_manager, octobot_trading.exchange_data.PreloadedCandlesManager) and max_history: return candles_manager.high_candles return candles_manager.get_symbol_high_candles(-1 if max_history else limit) # Use capital letters to avoid python native lib conflicts async def Low(context, symbol=None, time_frame=None, limit=-1, max_history=False): candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) if isinstance(candles_manager, octobot_trading.exchange_data.PreloadedCandlesManager) and max_history: return candles_manager.low_candles return candles_manager.get_symbol_low_candles(-1 if max_history else limit) # Use capital letters to avoid python native lib conflicts async def Close(context, symbol=None, time_frame=None, limit=-1, max_history=False): candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) if isinstance(candles_manager, octobot_trading.exchange_data.PreloadedCandlesManager) and max_history: return candles_manager.close_candles return candles_manager.get_symbol_close_candles(-1 if max_history else limit) async def hl2(context, symbol=None, time_frame=None, limit=-1, max_history=False): try: from tentacles.Evaluator.Util.candles_util import CandlesUtil candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) return CandlesUtil.HL2( candles_manager.get_symbol_high_candles(-1 if max_history else limit), candles_manager.get_symbol_low_candles(-1 if max_history else limit) ) except ImportError: raise RuntimeError("CandlesUtil tentacle is required to use HL2") async def hlc3(context, symbol=None, time_frame=None, limit=-1, max_history=False): try: from tentacles.Evaluator.Util.candles_util import CandlesUtil candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) return CandlesUtil.HLC3( candles_manager.get_symbol_high_candles(-1 if max_history else limit), candles_manager.get_symbol_low_candles(-1 if max_history else limit), candles_manager.get_symbol_close_candles(-1 if max_history else limit) ) except ImportError: raise RuntimeError("CandlesUtil tentacle is required to use HLC3") async def ohlc4(context, symbol=None, time_frame=None, limit=-1, max_history=False): try: from tentacles.Evaluator.Util.candles_util import CandlesUtil candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) return CandlesUtil.OHLC4( candles_manager.get_symbol_open_candles(-1 if max_history else limit), candles_manager.get_symbol_high_candles(-1 if max_history else limit), candles_manager.get_symbol_low_candles(-1 if max_history else limit), candles_manager.get_symbol_close_candles(-1 if max_history else limit) ) except ImportError: raise RuntimeError("CandlesUtil tentacle is required to use OHLC4") # Use capital letters to avoid python native lib conflicts async def Volume(context, symbol=None, time_frame=None, limit=-1, max_history=False): candles_manager = await _get_candle_manager(context, symbol, time_frame, max_history) if isinstance(candles_manager, octobot_trading.exchange_data.PreloadedCandlesManager) and max_history: return candles_manager.close_candles return candles_manager.get_symbol_volume_candles(-1 if max_history else limit) async def get_candles_from_name(ctx, source_name="low", time_frame=None, symbol=None, limit=-1, max_history=False): """ source_name can be: "open", "high", "low", "close", "hl2", "hlc3", "ohlc4", "volume", "Heikin Ashi close", "Heikin Ashi open", "Heikin Ashi high", "Heikin Ashi low" """ symbol = symbol or ctx.symbol time_frame = time_frame or ctx.time_frame if source_name == "close": return await Close(ctx, symbol, time_frame, limit, max_history) if source_name == "open": return await Open(ctx, symbol, time_frame, limit, max_history) if source_name == "high": return await High(ctx, symbol, time_frame, limit, max_history) if source_name == "low": return await Low(ctx, symbol, time_frame, limit, max_history) if source_name == "volume": return await Volume(ctx, symbol, time_frame, limit, max_history) if source_name == "time": return await Time(ctx, symbol, time_frame, limit, max_history) if source_name == "hl2": return await hl2(ctx, symbol, time_frame, limit, max_history) if source_name == "hlc3": return await hlc3(ctx, symbol, time_frame, limit, max_history) if source_name == "ohlc4": return await ohlc4(ctx, symbol, time_frame, limit, max_history) if "Heikin Ashi" in source_name: haOpen, haHigh, haLow, haClose = CandlesUtil.HeikinAshi(await Open(ctx, symbol, time_frame, limit, max_history), await High(ctx, symbol, time_frame, limit, max_history), await Low(ctx, symbol, time_frame, limit, max_history), await Close(ctx, symbol, time_frame, limit, max_history) ) if source_name == "Heikin Ashi close": return haClose if source_name == "Heikin Ashi open": return haOpen if source_name == "Heikin Ashi high": return haHigh if source_name == "Heikin Ashi low": return haLow async def _local_candles_manager(exchange_manager, symbol, time_frame, start_timestamp, end_timestamp): # warning: should only be called with an exchange simulator (in backtesting) ohlcv_data: list = await exchange_manager.exchange.exchange_importers[0].get_ohlcv( exchange_name=exchange_manager.exchange_name, symbol=symbol, time_frame=commons_enums.TimeFrames(time_frame)) chronological_candles = sorted(ohlcv_data, key=lambda candle: candle[0]) full_candles_history = [ ohlcv[-1] for ohlcv in chronological_candles if start_timestamp <= ohlcv[0] <= end_timestamp ] candles_manager = exchange_data.CandlesManager(max_candles_count=len(full_candles_history)) await candles_manager.initialize() candles_manager.replace_all_candles(full_candles_history) return candles_manager async def _get_candle_manager(context, symbol, time_frame, max_history): symbol = symbol or context.symbol time_frame = time_frame or context.time_frame candle_manager = api.get_symbol_candles_manager( api.get_symbol_data(context.exchange_manager, symbol, allow_creation=False), time_frame ) if max_history and context.exchange_manager.is_backtesting: if isinstance(candle_manager, octobot_trading.exchange_data.PreloadedCandlesManager): return candle_manager start_timestamp = backtesting_api.get_backtesting_starting_time(context.exchange_manager.exchange.backtesting) end_timestamp = backtesting_api.get_backtesting_ending_time(context.exchange_manager.exchange.backtesting) _key = symbol + time_frame + str(start_timestamp) + str(end_timestamp) try: return run_persistence.get_shared_element(_key) except KeyError: run_persistence.set_shared_element( _key, await _local_candles_manager( context.exchange_manager, symbol, time_frame, start_timestamp, end_timestamp ) ) return run_persistence.get_shared_element(_key) return candle_manager def get_digits_adapted_price(context, price, truncate=True): symbol_market = context.exchange_manager.exchange.get_market_status(context.symbol, with_fixer=False) return personal_data.decimal_adapt_price(symbol_market, price, truncate=truncate) def get_digits_adapted_amount(context, amount, truncate=True): symbol_market = context.exchange_manager.exchange.get_market_status(context.symbol, with_fixer=False) return personal_data.decimal_adapt_quantity(symbol_market, amount, truncate=truncate) ================================================ FILE: Meta/Keywords/scripting_library/data/reading/metadata_reader.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.enums as commons_enums import octobot_commons.databases as databases class MetadataReader(databases.DBReader): async def read(self) -> list: return await self.all(commons_enums.DBTables.METADATA.value) ================================================ FILE: Meta/Keywords/scripting_library/data/reading/trading_settings.py ================================================ def set_initialized_evaluation(ctx, trading_mode, initialized=True, symbol=None, time_frame=None): trading_mode.set_initialized_trading_pair_by_bot_id(symbol or ctx.symbol, time_frame or ctx.time_frame, initialized) def get_initialized_evaluation(ctx, trading_mode, symbol=None, time_frame=None): return trading_mode.get_initialized_trading_pair_by_bot_id(symbol or ctx.symbol, time_frame or ctx.time_frame) def are_all_evaluation_initialized(ctx, trading_mode): for symbol in ctx.exchange_manager.exchange_config.traded_symbol_pairs: for time_frame in ctx.exchange_manager.exchange_config.get_relevant_time_frames(): try: if not get_initialized_evaluation(ctx, trading_mode, symbol=symbol, time_frame=time_frame.value): return False except KeyError: return False return True ================================================ FILE: Meta/Keywords/scripting_library/data/writing/__init__.py ================================================ from .plotting import * from .portfolio import * ================================================ FILE: Meta/Keywords/scripting_library/data/writing/plotting.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_public_data as exchange_public_data import octobot_trading.modes.script_keywords as script_keywords import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants async def disable_candles_plot(ctx, time_frame=None): time_frame = time_frame or ctx.time_frame if not ctx.symbol_writer.are_data_initialized_by_key.get(time_frame): await script_keywords.disable_candles_plot(None, ctx.exchange_manager) async def plot(ctx, title, x=None, y=None, z=None, open=None, high=None, low=None, close=None, volume=None, text=None, kind="scattergl", mode="lines", line_shape="linear", condition=None, x_function=exchange_public_data.Time, x_multiplier=1000, time_frame=None, chart=commons_enums.PlotCharts.SUB_CHART.value, cache_value=None, own_yaxis=False, color=None, size=None, shape=None, shift_to_open_candle_time=True): time_frame = time_frame or ctx.time_frame if condition is not None and cache_value is None: if isinstance(ctx.symbol_writer.get_serializable_value(condition), bool): if condition: x = numpy.array(((await x_function(ctx, ctx.symbol, time_frame))[-1],)) y = numpy.array((y[-1],)) else: x = [] y = [] else: candidate_y = [] candidate_x = [] x_data = (await x_function(ctx, ctx.symbol, time_frame))[-len(condition):] y_data = y[-len(condition):] for index, value in enumerate(condition): if value: candidate_y.append(y_data[index]) candidate_x.append(x_data[index]) x = numpy.array(candidate_x) y = numpy.array(candidate_y) count_query = { "time_frame": ctx.time_frame, } cache_full_path = None if cache_value is not None: cache_full_path = ctx.get_cache_path(ctx.tentacle) count_query["title"] = title count_query["value"] = cache_full_path x_shift = -commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(ctx.time_frame)] * \ commons_constants.MINUTE_TO_SECONDS if shift_to_open_candle_time else 0 if not await ctx.symbol_writer.contains_row( commons_enums.DBTables.CACHE_SOURCE.value if cache_value is not None else title, count_query ): if cache_value is not None: table = commons_enums.DBTables.CACHE_SOURCE.value # save x_shift to be applied when displaying and not to change actual cached values cache_data = { "title": title, "text": text, "time_frame": ctx.time_frame, "value": cache_full_path, "cache_value": cache_value, "kind": kind, "mode": mode, "line_shape": line_shape, "chart": chart, "own_yaxis": own_yaxis, "condition": condition, "color": color, "size": size, "shape": shape, "x_shift": x_shift, } update_query = await ctx.symbol_writer.search() update_query = ((update_query.kind == kind) & (update_query.mode == mode) & (update_query.time_frame == ctx.time_frame) & (update_query.title == title)) await ctx.symbol_writer.upsert(table, cache_data, update_query) else: adapted_x = None if x is not None: try: min_available_data = len(x) except TypeError: min_available_data = None if y is not None: min_available_data = len(y) if isinstance(y, list) and not isinstance(x, list): x = [x] * len(y) if z is not None: min_available_data = len(z) if min_available_data is None else min(min_available_data, len(z)) if isinstance(z, list) and not isinstance(z, list): x = [x] * len(z) adapted_x = x[-min_available_data:] if min_available_data != len(x) else x if adapted_x is None: raise RuntimeError("No confirmed adapted_x") adapted_x = [(a_x + x_shift) * x_multiplier for a_x in adapted_x] if isinstance(adapted_x, list) \ else adapted_x * x_multiplier await ctx.symbol_writer.log_many( title, [ { "x": value, "y": _get_value_from_array(y, index), "z": _get_value_from_array(z, index), "open": _get_value_from_array(open, index), "high": _get_value_from_array(high, index), "low": _get_value_from_array(low, index), "close": _get_value_from_array(close, index), "volume": _get_value_from_array(volume, index), "time_frame": ctx.time_frame, "kind": kind, "mode": mode, "line_shape": line_shape, "chart": chart, "own_yaxis": own_yaxis, "color": color, "text": text, "size": size, "shape": shape, } for index, value in enumerate(adapted_x) ], cache=False ) elif cache_value is None and x is not None: if isinstance(y, list) and not isinstance(x, list): x = [x] * len(y) elif isinstance(z, list) and not isinstance(x, list): x = [x] * len(z) if len(x) and \ not await ctx.symbol_writer.contains_row(title, {"x": _get_value_from_array(x, -1) * x_multiplier}): x_value = (_get_value_from_array(x, -1) + x_shift) * x_multiplier await ctx.symbol_writer.upsert( title, { "time_frame": ctx.time_frame, "x": x_value, "y": _get_value_from_array(y, -1), "z": _get_value_from_array(z, -1), "open": _get_value_from_array(open, -1), "high": _get_value_from_array(high, -1), "low": _get_value_from_array(low, -1), "close": _get_value_from_array(close, -1), "volume": _get_value_from_array(volume, -1), "kind": kind, "mode": mode, "line_shape": line_shape, "chart": chart, "own_yaxis": own_yaxis, "color": color, "text": text, "size": size, "shape": shape, }, None, cache_query={"x": x_value} ) async def plot_shape(ctx, title, value, y_value, chart=commons_enums.PlotCharts.SUB_CHART.value, kind="scattergl", mode="markers", line_shape="linear", x_multiplier=1000): if not await ctx.symbol_writer.contains_row(title, { "x": ctx.x, "time_frame": ctx.time_frame }): await ctx.symbol_writer.log( title, { "time_frame": ctx.time_frame, "x": (await exchange_public_data.current_candle_time(ctx)) * x_multiplier, "y": y_value, "value": ctx.symbol_writer.get_serializable_value(value), "kind": kind, "mode": mode, "line_shape": line_shape, "chart": chart, } ) def _get_value_from_array(array, index, multiplier=1): if array is None: return None return array[index] * multiplier ================================================ FILE: Meta/Keywords/scripting_library/data/writing/portfolio.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.errors as trading_errors import octobot_trading.modes.script_keywords as script_keywords import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_private_data as exchange_private_data async def withdraw(context, amount, currency): if not context.exchange_manager.is_backtesting: raise RuntimeError("withdraw is only supported in backtesting") amount_type, amount_value = script_keywords.parse_quantity(amount) if amount_type is script_keywords.QuantityType.UNKNOWN or amount_value <= 0: raise trading_errors.InvalidArgumentError("amount cant be zero or negative") if amount_type in (script_keywords.QuantityType.DELTA, script_keywords.QuantityType.DELTA_BASE): # nothing to do pass elif amount_type is script_keywords.QuantityType.PERCENT: amount_value = script_keywords.account_holdings(context, currency) * amount_value / 100 else: raise trading_errors.InvalidArgumentError("make sure to use a supported syntax for amount") await context.trader.withdraw(amount_value, currency) ================================================ FILE: Meta/Keywords/scripting_library/errors.py ================================================ class ScriptedLibraryError(Exception): pass class InvalidBacktestingDataError(ScriptedLibraryError): pass class MissingReadOnlyExchangeCredentialsError(ScriptedLibraryError): pass class InvalidProfileError(ScriptedLibraryError): pass class InvalidTentacleProfileError(InvalidProfileError): pass ================================================ FILE: Meta/Keywords/scripting_library/exchanges/__init__.py ================================================ from tentacles.Meta.Keywords.scripting_library.exchanges.local_exchange import * ================================================ FILE: Meta/Keywords/scripting_library/exchanges/local_exchange.py ================================================ import contextlib import typing import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.exchange_data as exchange_data_import import tentacles.Meta.Keywords.scripting_library.configuration.profile_data_configuration as profile_data_configuration @contextlib.asynccontextmanager async def local_ccxt_exchange_manager( exchange_data: exchange_data_import.ExchangeData, tentacles_setup_config, exchange_config_by_exchange: typing.Optional[dict[str, dict]] = None, ): exchange_config = profile_data_configuration.get_exchange_config( exchange_data, tentacles_setup_config, exchange_config_by_exchange, False ) ignore_config = not profile_data_configuration.is_auth_required_exchanges( exchange_data, tentacles_setup_config, exchange_config_by_exchange ) async with exchanges.get_local_exchange_manager( exchange_data.exchange_details.name, exchange_config, tentacles_setup_config, exchange_data.auth_details.sandboxed, ignore_config=ignore_config, use_cached_markets=True, is_broker_enabled=exchange_data.auth_details.broker_enabled, exchange_config_by_exchange=exchange_config_by_exchange, disable_unauth_retry=True, # unauth fallback is never required here, if auth fails, this should fail ) as exchange_manager: yield exchange_manager ================================================ FILE: Meta/Keywords/scripting_library/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": [], "tentacles-requirements": [] } ================================================ FILE: Meta/Keywords/scripting_library/orders/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .order_types import * from .position_size import * from .order_tags import * from .grouping import * from .cancelling import * from .editing import * from .chaining import * from .open_orders import * from .waiting import * from .mocks import * ================================================ FILE: Meta/Keywords/scripting_library/orders/cancelling.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as enums import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords import tentacles.Meta.Keywords.scripting_library.orders.order_tags as order_tags async def cancel_orders( ctx, which="all", symbol=None, symbols=None, cancel_loaded_orders=True, since: int or float = -1, until: int or float = -1, ) -> bool: symbols = symbols or [symbol] if symbol or symbols else [ctx.symbol] orders = None orders_canceled = False side = None if which == "all": side = None elif which == "sell": side = enums.TradeOrderSide.SELL elif which == "buy": side = enums.TradeOrderSide.BUY else: # tagged order orders = order_tags.get_tagged_orders( ctx, which, symbol=symbol, since=since, until=until) if orders is not None: for order in orders: if await ctx.trader.cancel_order(order): orders_canceled = True if basic_keywords.is_emitting_trading_signals(ctx): ctx.get_signal_builder().add_cancelled_order(order, ctx.trader.exchange_manager) else: for symbol in symbols: orders_canceled, orders = await ctx.trader.cancel_open_orders( symbol, cancel_loaded_orders=cancel_loaded_orders, side=side, since=since, until=until) if basic_keywords.is_emitting_trading_signals(ctx): for order in orders: ctx.get_signal_builder().add_cancelled_order(order, ctx.trader.exchange_manager) return orders_canceled ================================================ FILE: Meta/Keywords/scripting_library/orders/chaining.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.personal_data as personal_data async def chain_order(base_order, chained_orders, update_with_triggering_order_fees=False) -> list: # order creation return a list by default, handle it here orders = [] if isinstance(base_order, list): if not base_order: return orders base_order = base_order[0] if not isinstance(chained_orders, list): chained_orders = [chained_orders] for order in chained_orders: await order.set_as_chained_order(base_order, False, {}, update_with_triggering_order_fees) base_order.add_chained_order(order) if base_order.is_filled() and order.should_be_created(): await personal_data.create_as_chained_order(order) orders.append(order) return orders ================================================ FILE: Meta/Keywords/scripting_library/orders/editing.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import octobot_trading.constants as trading_constants import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords async def edit_order(ctx, order, edited_quantity: decimal.Decimal = None, edited_price: decimal.Decimal = None, edited_stop_price: decimal.Decimal = None, edited_current_price: decimal.Decimal = None, params: dict = None) -> bool: if not ctx.enable_trading: return False changed = await ctx.trader.edit_order( order, edited_quantity=edited_quantity, edited_price=edited_price, edited_stop_price=edited_stop_price, edited_current_price=edited_current_price, params=params, ) if basic_keywords.is_emitting_trading_signals(ctx): ctx.get_signal_builder().add_edited_order( order, ctx.trader.exchange_manager, updated_quantity=trading_constants.ZERO if edited_quantity is None else edited_quantity, updated_limit_price=trading_constants.ZERO if edited_price is None else edited_price, updated_stop_price=trading_constants.ZERO if edited_stop_price is None else edited_stop_price, updated_current_price=trading_constants.ZERO if edited_current_price is None else edited_current_price ) return changed ================================================ FILE: Meta/Keywords/scripting_library/orders/grouping.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.personal_data as trading_personal_data import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords def create_one_cancels_the_other_group(context, group_identifier=None, orders=None) \ -> trading_personal_data.OneCancelsTheOtherOrderGroup: """ Should be used to create temporary groups binding localized orders, where this group can be created once and directly associated to each order """ return _create_order_group(context, trading_personal_data.OneCancelsTheOtherOrderGroup, group_identifier, orders) def get_or_create_one_cancels_the_other_group( context, orders=None, include_chained_orders=True, group_identifier=None) -> trading_personal_data.OneCancelsTheOtherOrderGroup: """ Should be used to manage long lasting groups that are meant to be re-used First: looks for groups in orders Second: looks for groups named as group_identifier Third: creates a group named as group_identifier """ if group := get_group_from_orders(orders, include_chained_orders=include_chained_orders): return group return _get_or_create_order_group(context, trading_personal_data.OneCancelsTheOtherOrderGroup, group_identifier) def create_balanced_take_profit_and_stop_group(context, group_identifier=None, orders=None) \ -> trading_personal_data.BalancedTakeProfitAndStopOrderGroup: """ Should be used to create temporary groups binding localized orders, where this group can be created once and directly associated to each order """ return _create_order_group(context, trading_personal_data.BalancedTakeProfitAndStopOrderGroup, group_identifier, orders) def get_or_create_balanced_take_profit_and_stop_group( context, orders=None, include_chained_orders=True, group_identifier=None) -> trading_personal_data.BalancedTakeProfitAndStopOrderGroup: """ Should be used to manage long lasting groups that are meant to be re-used First: looks for groups in orders Second: looks for groups named as group_identifier Third: creates a group named as group_identifier """ if group := get_group_from_orders(orders, include_chained_orders=include_chained_orders): return group return _get_or_create_order_group(context, trading_personal_data.BalancedTakeProfitAndStopOrderGroup, group_identifier) def add_orders_to_group(ctx, order_group, orders): orders = orders if isinstance(orders, list) else [orders] for order in orders: order.add_to_order_group(order_group) if basic_keywords.is_emitting_trading_signals(ctx): ctx.get_signal_builder().add_order_to_group(order, ctx.exchange_manager) def get_group_from_orders(orders, include_chained_orders=True): if orders is None: return None orders = orders if isinstance(orders, list) else [orders] for order in orders: if order.order_group is not None: return order.order_group if include_chained_orders: if group := get_group_from_orders(order.chained_orders): return group return None def get_open_orders_from_group(order_group): return order_group.get_group_open_orders() async def enable_group(order_group, enabled): await order_group.enable(enabled) def _create_order_group(context, group_type, group_identifier, orders) -> trading_personal_data.OrderGroup: group = context.exchange_manager.exchange_personal_data.orders_manager.create_group(group_type, group_identifier) if orders is not None: add_orders_to_group(context, group, orders) return group def _get_or_create_order_group(context, group_type, group_identifier) -> trading_personal_data.OrderGroup: return context.exchange_manager.exchange_personal_data.orders_manager.get_or_create_group(group_type, group_identifier) ================================================ FILE: Meta/Keywords/scripting_library/orders/mocks.py ================================================ import decimal import octobot_trading.personal_data as personal_data def minimal_order_amount(symbol): return BYBIT_SYMBOLS_LIMIT_MIN_AMOUNT_EXTRACT[symbol] def max_digits(symbol): return BYBIT_SYMBOLS_AMOUNT_MAX_DIGITS_EXTRACT[symbol] def adapt_digits(symbol, value): if value is not None: return personal_data.decimal_trunc_with_n_decimal_digits( decimal.Decimal(str(value)), decimal.Decimal(str(max_digits(symbol))), truncate=True ) return value # todo remove when symbol market status in backtesting data files # extract from nov 2 2022 BYBIT_SYMBOLS_LIMIT_MIN_AMOUNT_EXTRACT = { "1INCH/USDT:USDT": 0.1, "AAVE/USDT:USDT": 0.01, "ACH/USDT:USDT": 10.0, "ADA/USD:ADA": 1.0, "ADA/USDT:USDT": 1.0, "AGLD/USDT:USDT": 0.1, "AKRO/USDT:USDT": 100.0, "ALGO/USDT:USDT": 0.1, "ALICE/USDT:USDT": 0.1, "ALPHA/USDT:USDT": 1.0, "ANKR/USDT:USDT": 1.0, "ANT/USDT:USDT": 0.1, "APE/USDT:USDT": 0.1, "API3/USDT:USDT": 0.1, "APT/USDT:USDT": 0.01, "ARPA/USDT:USDT": 10.0, "AR/USDT:USDT": 0.1, "ASTR/USDT:USDT": 1.0, "ATOM/USDT:USDT": 0.1, "AUDIO/USDT:USDT": 0.1, "AVAX/USDT:USDT": 0.1, "AXS/USDT:USDT": 0.1, "BAKE/USDT:USDT": 0.1, "BAL/USDT:USDT": 0.01, "BAND/USDT:USDT": 0.1, "BAT/USDT:USDT": 0.1, "BCH/USDT:USDT": 0.01, "BEL/USDT:USDT": 1.0, "BICO/USDT:USDT": 0.1, "BIT/USD:BIT": 1.0, "BIT/USDT:USDT": 0.1, "BLZ/USDT:USDT": 1.0, "BNB/USDT:USDT": 0.01, "BNX/USDT:USDT": 0.01, "BOBA/USDT:USDT": 0.1, "BSV/USDT:USDT": 0.01, "BSW/USDT:USDT": 1.0, "BTC/USD:BTC": 1.0, "BTC/USDT:USDT": 0.001, "C98/USDT:USDT": 0.1, "CEEK/USDT:USDT": 1.0, "CELO/USDT:USDT": 0.1, "CELR/USDT:USDT": 1.0, "CHR/USDT:USDT": 0.1, "CHZ/USDT:USDT": 1.0, "CKB/USDT:USDT": 10.0, "COMP/USDT:USDT": 0.01, "COTI/USDT:USDT": 1.0, "CREAM/USDT:USDT": 0.01, "CRO/USDT:USDT": 1.0, "CRV/USDT:USDT": 0.1, "CTC/USDT:USDT": 1.0, "CTK/USDT:USDT": 0.1, "CTSI/USDT:USDT": 1.0, "CVC/USDT:USDT": 1.0, "CVX/USDT:USDT": 0.01, "DAR/USDT:USDT": 0.1, "DASH/USDT:USDT": 0.01, "DENT/USDT:USDT": 100.0, "DGB/USDT:USDT": 10.0, "DODO/USDT:USDT": 1.0, "DOGE/USDT:USDT": 1.0, "DOT/USD:DOT": 1.0, "DOT/USDT:USDT": 0.1, "DUSK/USDT:USDT": 1.0, "DYDX/USDT:USDT": 0.1, "EGLD/USDT:USDT": 0.01, "ENJ/USDT:USDT": 0.1, "ENS/USDT:USDT": 0.1, "EOS/USD:EOS": 1.0, "EOS/USDT:USDT": 0.1, "ETC/USDT:USDT": 0.1, "ETH/USD:ETH": 1.0, "ETH/USDT:USDT": 0.01, "ETHW/USDT:USDT": 0.01, "FIL/USDT:USDT": 0.1, "FITFI/USDT:USDT": 1.0, "FLM/USDT:USDT": 1.0, "FLOW/USDT:USDT": 0.1, "FTM/USDT:USDT": 1.0, "FTT/USDT:USDT": 0.1, "FXS/USDT:USDT": 0.01, "GALA/USDT:USDT": 1.0, "GAL/USDT:USDT": 0.01, "GLMR/USDT:USDT": 0.1, "GMT/USDT:USDT": 1.0, "GMX/USDT:USDT": 0.01, "GRT/USDT:USDT": 0.1, "GTC/USDT:USDT": 0.1, "HBAR/USDT:USDT": 1.0, "HNT/USDT:USDT": 0.01, "HOT/USDT:USDT": 100.0, "ICP/USDT:USDT": 0.1, "ICX/USDT:USDT": 1.0, "ILV/USDT:USDT": 0.01, "IMX/USDT:USDT": 0.1, "INJ/USDT:USDT": 0.1, "IOST/USDT:USDT": 1.0, "IOTA/USDT:USDT": 0.1, "IOTX/USDT:USDT": 1.0, "JASMY/USDT:USDT": 1.0, "JST/USDT:USDT": 10.0, "KAVA/USDT:USDT": 0.1, "KDA/USDT:USDT": 0.1, "KLAY/USDT:USDT": 0.1, "KNC/USDT:USDT": 0.1, "KSM/USDT:USDT": 0.01, "LDO/USDT:USDT": 0.1, "LINA/USDT:USDT": 10.0, "LINK/USDT:USDT": 0.1, "LIT/USDT:USDT": 0.1, "LOOKS/USDT:USDT": 0.1, "LPT/USDT:USDT": 0.1, "LRC/USDT:USDT": 0.1, "LTC/USD:LTC": 1.0, "LTC/USDT:USDT": 0.1, "LUNA2/USDT:USDT": 0.1, "MANA/USD:MANA": 1.0, "MANA/USDT:USDT": 0.1, "MASK/USDT:USDT": 0.1, "MATIC/USDT:USDT": 1.0, "MINA/USDT:USDT": 0.1, "MKR/USDT:USDT": 0.001, "MTL/USDT:USDT": 0.1, "NEAR/USDT:USDT": 0.1, "NEO/USDT:USDT": 0.01, "OCEAN/USDT:USDT": 1.0, "OGN/USDT:USDT": 1.0, "OMG/USDT:USDT": 0.1, "ONE/USDT:USDT": 1.0, "ONT/USDT:USDT": 1.0, "OP/USDT:USDT": 0.1, "PAXG/USDT:USDT": 0.001, "PEOPLE/USDT:USDT": 1.0, "QTUM/USDT:USDT": 0.1, "RAY/USDT:USDT": 0.1, "REEF/USDT:USDT": 10.0, "REN/USDT:USDT": 0.1, "REQ/USDT:USDT": 1.0, "RNDR/USDT:USDT": 0.1, "ROSE/USDT:USDT": 1.0, "RSR/USDT:USDT": 10.0, "RSS3/USDT:USDT": 1.0, "RUNE/USDT:USDT": 0.1, "RVN/USDT:USDT": 1.0, "SAND/USDT:USDT": 1.0, "SCRT/USDT:USDT": 0.1, "SC/USDT:USDT": 10.0, "SFP/USDT:USDT": 0.1, "SKL/USDT:USDT": 1.0, "SLP/USDT:USDT": 10.0, "SNX/USDT:USDT": 0.1, "SOL/USD:SOL": 1.0, "SOL/USDT:USDT": 0.1, "SPELL/USDT:USDT": 10.0, "SRM/USDT:USDT": 0.1, "STG/USDT:USDT": 0.1, "STMX/USDT:USDT": 10.0, "STORJ/USDT:USDT": 0.1, "STX/USDT:USDT": 0.1, "SUN/USDT:USDT": 10.0, "SUSHI/USDT:USDT": 0.1, "SXP/USDT:USDT": 0.1, "THETA/USDT:USDT": 0.1, "TLM/USDT:USDT": 1.0, "TOMO/USDT:USDT": 0.1, "TRB/USDT:USDT": 0.01, "TRX/USDT:USDT": 1.0, "UNFI/USDT:USDT": 0.1, "UNI/USDT:USDT": 0.1, "USDC/USDT:USDT": 0.1, "VET/USDT:USDT": 1.0, "WAVES/USDT:USDT": 0.1, "WOO/USDT:USDT": 0.1, "XCN/USDT:USDT": 10.0, "XEM/USDT:USDT": 1.0, "XLM/USDT:USDT": 1.0, "XMR/USDT:USDT": 0.01, "XNO/USDT:USDT": 1.0, "XRP/USD:XRP": 1.0, "XRP/USDT:USDT": 1.0, "XTZ/USDT:USDT": 0.1, "YFI/USDT:USDT": 0.0001, "YGG/USDT:USDT": 0.1, "ZEC/USDT:USDT": 0.01, "ZEN/USDT:USDT": 0.1, "ZIL/USDT:USDT": 10.0, "ZRX/USDT:USDT": 1.0, "BTC/USD:USDC": 0.001, "ETC/USD:USDC": 0.1, "MATIC/USD:USDC": 1.0, "OP/USD:USDC": 1.0, "ETH/USD:USDC": 0.01, "GMT/USD:USDC": 1.0, "ADA/USD:USDC": 1.0, "AVAX/USD:USDC": 0.01, "SOL/USD:USDC": 0.1, "XRP/USD:USDC": 1.0, "SAND/USD:USDC": 1.0, "APE/USD:USDC": 0.1, "SWEAT/USD:USDC": 100.0, "ATOM/USD:USDC": 0.1, "EOS/USD:USDC": 0.1, "CHZ/USD:USDC": 1.0, "NEAR/USD:USDC": 0.1, "BNB/USD:USDC": 0.01, "LDO/USD:USDC": 0.1, "LUNA/USD:USDC": 0.1, "APT/USD:USDC": 0.01} BYBIT_SYMBOLS_AMOUNT_MAX_DIGITS_EXTRACT = { "1INCH/USDT:USDT": 1, "AAVE/USDT:USDT": 2, "ACH/USDT:USDT": 1, "ADA/USD:ADA": 0, "ADA/USDT:USDT": 0, "AGLD/USDT:USDT": 1, "AKRO/USDT:USDT": 2, "ALGO/USDT:USDT": 1, "ALICE/USDT:USDT": 1, "ALPHA/USDT:USDT": 0, "ANKR/USDT:USDT": 0, "ANT/USDT:USDT": 1, "APE/USDT:USDT": 1, "API3/USDT:USDT": 1, "APT/USDT:USDT": 2, "ARPA/USDT:USDT": 1, "AR/USDT:USDT": 1, "ASTR/USDT:USDT": 0, "ATOM/USDT:USDT": 1, "AUDIO/USDT:USDT": 1, "AVAX/USDT:USDT": 1, "AXS/USDT:USDT": 1, "BAKE/USDT:USDT": 1, "BAL/USDT:USDT": 2, "BAND/USDT:USDT": 1, "BAT/USDT:USDT": 1, "BCH/USDT:USDT": 2, "BEL/USDT:USDT": 0, "BICO/USDT:USDT": 1, "BIT/USD:BIT": 0, "BIT/USDT:USDT": 1, "BLZ/USDT:USDT": 0, "BNB/USDT:USDT": 2, "BNX/USDT:USDT": 2, "BOBA/USDT:USDT": 1, "BSV/USDT:USDT": 2, "BSW/USDT:USDT": 0, "BTC/USD:BTC": 0, "BTC/USDT:USDT": 3, "C98/USDT:USDT": 1, "CEEK/USDT:USDT": 0, "CELO/USDT:USDT": 1, "CELR/USDT:USDT": 0, "CHR/USDT:USDT": 1, "CHZ/USDT:USDT": 0, "CKB/USDT:USDT": 1, "COMP/USDT:USDT": 2, "COTI/USDT:USDT": 0, "CREAM/USDT:USDT": 2, "CRO/USDT:USDT": 0, "CRV/USDT:USDT": 1, "CTC/USDT:USDT": 0, "CTK/USDT:USDT": 1, "CTSI/USDT:USDT": 0, "CVC/USDT:USDT": 0, "CVX/USDT:USDT": 2, "DAR/USDT:USDT": 1, "DASH/USDT:USDT": 2, "DENT/USDT:USDT": 2, "DGB/USDT:USDT": 1, "DODO/USDT:USDT": 0, "DOGE/USDT:USDT": 0, "DOT/USD:DOT": 0, "DOT/USDT:USDT": 1, "DUSK/USDT:USDT": 0, "DYDX/USDT:USDT": 1, "EGLD/USDT:USDT": 2, "ENJ/USDT:USDT": 1, "ENS/USDT:USDT": 1, "EOS/USD:EOS": 0, "EOS/USDT:USDT": 1, "ETC/USDT:USDT": 1, "ETH/USD:ETH": 0, "ETH/USDT:USDT": 2, "ETHW/USDT:USDT": 2, "FIL/USDT:USDT": 1, "FITFI/USDT:USDT": 0, "FLM/USDT:USDT": 0, "FLOW/USDT:USDT": 1, "FTM/USDT:USDT": 0, "FTT/USDT:USDT": 1, "FXS/USDT:USDT": 2, "GALA/USDT:USDT": 0, "GAL/USDT:USDT": 2, "GLMR/USDT:USDT": 1, "GMT/USDT:USDT": 0, "GMX/USDT:USDT": 2, "GRT/USDT:USDT": 1, "GTC/USDT:USDT": 1, "HBAR/USDT:USDT": 0, "HNT/USDT:USDT": 2, "HOT/USDT:USDT": 2, "ICP/USDT:USDT": 1, "ICX/USDT:USDT": 0, "ILV/USDT:USDT": 2, "IMX/USDT:USDT": 1, "INJ/USDT:USDT": 1, "IOST/USDT:USDT": 0, "IOTA/USDT:USDT": 1, "IOTX/USDT:USDT": 0, "JASMY/USDT:USDT": 0, "JST/USDT:USDT": 1, "KAVA/USDT:USDT": 1, "KDA/USDT:USDT": 1, "KLAY/USDT:USDT": 1, "KNC/USDT:USDT": 1, "KSM/USDT:USDT": 2, "LDO/USDT:USDT": 1, "LINA/USDT:USDT": 1, "LINK/USDT:USDT": 1, "LIT/USDT:USDT": 1, "LOOKS/USDT:USDT": 1, "LPT/USDT:USDT": 1, "LRC/USDT:USDT": 1, "LTC/USD:LTC": 0, "LTC/USDT:USDT": 1, "LUNA2/USDT:USDT": 1, "MANA/USD:MANA": 0, "MANA/USDT:USDT": 1, "MASK/USDT:USDT": 1, "MATIC/USDT:USDT": 0, "MINA/USDT:USDT": 1, "MKR/USDT:USDT": 3, "MTL/USDT:USDT": 1, "NEAR/USDT:USDT": 1, "NEO/USDT:USDT": 2, "OCEAN/USDT:USDT": 0, "OGN/USDT:USDT": 0, "OMG/USDT:USDT": 1, "ONE/USDT:USDT": 0, "ONT/USDT:USDT": 0, "OP/USDT:USDT": 1, "PAXG/USDT:USDT": 3, "PEOPLE/USDT:USDT": 0, "QTUM/USDT:USDT": 1, "RAY/USDT:USDT": 1, "REEF/USDT:USDT": 1, "REN/USDT:USDT": 1, "REQ/USDT:USDT": 0, "RNDR/USDT:USDT": 1, "ROSE/USDT:USDT": 0, "RSR/USDT:USDT": 1, "RSS3/USDT:USDT": 0, "RUNE/USDT:USDT": 1, "RVN/USDT:USDT": 0, "SAND/USDT:USDT": 0, "SCRT/USDT:USDT": 1, "SC/USDT:USDT": 1, "SFP/USDT:USDT": 1, "SKL/USDT:USDT": 0, "SLP/USDT:USDT": 1, "SNX/USDT:USDT": 1, "SOL/USD:SOL": 0, "SOL/USDT:USDT": 1, "SPELL/USDT:USDT": 1, "SRM/USDT:USDT": 1, "STG/USDT:USDT": 1, "STMX/USDT:USDT": 1, "STORJ/USDT:USDT": 1, "STX/USDT:USDT": 1, "SUN/USDT:USDT": 1, "SUSHI/USDT:USDT": 1, "SXP/USDT:USDT": 1, "THETA/USDT:USDT": 1, "TLM/USDT:USDT": 0, "TOMO/USDT:USDT": 1, "TRB/USDT:USDT": 2, "TRX/USDT:USDT": 0, "UNFI/USDT:USDT": 1, "UNI/USDT:USDT": 1, "USDC/USDT:USDT": 1, "VET/USDT:USDT": 0, "WAVES/USDT:USDT": 1, "WOO/USDT:USDT": 1, "XCN/USDT:USDT": 1, "XEM/USDT:USDT": 0, "XLM/USDT:USDT": 0, "XMR/USDT:USDT": 2, "XNO/USDT:USDT": 0, "XRP/USD:XRP": 0, "XRP/USDT:USDT": 0, "XTZ/USDT:USDT": 1, "YFI/USDT:USDT": 4, "YGG/USDT:USDT": 1, "ZEC/USDT:USDT": 2, "ZEN/USDT:USDT": 1, "ZIL/USDT:USDT": 1, "ZRX/USDT:USDT": 0, "BTC/USD:USDC": 3, "ETC/USD:USDC": 1, "MATIC/USD:USDC": 0, "OP/USD:USDC": 0, "ETH/USD:USDC": 2, "GMT/USD:USDC": 0, "ADA/USD:USDC": 0, "AVAX/USD:USDC": 2, "SOL/USD:USDC": 1, "XRP/USD:USDC": 0, "SAND/USD:USDC": 0, "APE/USD:USDC": 1, "SWEAT/USD:USDC": 2, "ATOM/USD:USDC": 1, "EOS/USD:USDC": 1, "CHZ/USD:USDC": 0, "NEAR/USD:USDC": 1, "BNB/USD:USDC": 2, "LDO/USD:USDC": 1, "LUNA/USD:USDC": 1, "APT/USD:USDC": 2} ================================================ FILE: Meta/Keywords/scripting_library/orders/open_orders.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. def get_open_orders(context): return context.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol=context.symbol) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_tags.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. def get_tagged_orders( ctx, tag, symbol=None, since: int or float = -1, until: int or float = -1 ): return ctx.exchange_manager.exchange_personal_data.orders_manager.get_open_orders( symbol=symbol, tag=tag, since=since, until=until ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .limit_order import * from .market_order import * from .stop_loss_order import * from .trailing_market_order import * from .trailing_limit_order import * from .trailing_stop_loss_order import * from .scaled_order import * ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/create_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import octobot_trading.personal_data as trading_personal_data import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot_trading.modes.script_keywords.basic_keywords as basic_keywords import octobot_trading.modes.script_keywords as script_keywords import tentacles.Meta.Keywords.scripting_library.settings as settings import tentacles.Meta.Keywords.scripting_library.orders.position_size as position_size import tentacles.Meta.Keywords.scripting_library.orders.chaining as chaining import tentacles.Meta.Keywords.scripting_library.orders.grouping as grouping import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_private_data as exchange_private_data async def create_order_instance( context, side=None, symbol=None, order_amount=None, order_target_position=None, stop_loss_offset=None, stop_loss_tag=None, stop_loss_type=None, stop_loss_group=False, take_profit_offset=None, take_profit_tag=None, take_profit_type=None, take_profit_group=False, order_type_name=None, order_offset=None, order_min_offset=None, order_max_offset=None, order_limit_offset=None, # todo slippage_limit=None, time_limit=None, reduce_only=False, post_only=False, # Todo tag=None, group=None, wait_for=None ): if not context.enable_trading or _paired_order_is_closed(context, group): return [] async with context.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.lock: # ensure proper trader allow_artificial_orders value settings.set_allow_artificial_orders(context, context.allow_artificial_orders) unknown_portfolio_on_creation = wait_for is not None and any(o.is_open() for o in wait_for) input_side = side order_quantity, side = await _get_order_quantity_and_side(context, order_amount, order_target_position, order_type_name, input_side, reduce_only, unknown_portfolio_on_creation) order_type, order_price, final_side, reduce_only, trailing_method, \ min_offset_val, max_offset_val, order_limit_offset, limit_offset_val = \ await _get_order_details(context, order_type_name, side, order_offset, reduce_only, order_limit_offset) stop_loss_price = None if stop_loss_offset is None else await script_keywords.get_price_with_offset( context, stop_loss_offset ) take_profit_price = None if take_profit_offset is None else await script_keywords.get_price_with_offset( context, take_profit_offset ) # round down when not reduce only and up when reduce only to avoid letting small positions open truncate = not reduce_only return await _create_order(context=context, symbol=symbol, order_quantity=order_quantity, order_price=order_price, tag=tag, order_type_name=order_type_name, input_side=input_side, side=side, final_side=final_side, order_type=order_type, order_min_offset=order_min_offset, max_offset_val=max_offset_val, reduce_only=reduce_only, group=group, stop_loss_price=stop_loss_price, stop_loss_tag=stop_loss_tag, stop_loss_type=stop_loss_type, stop_loss_group=stop_loss_group, take_profit_price=take_profit_price, take_profit_tag=take_profit_tag, take_profit_type=take_profit_type, take_profit_group=take_profit_group, wait_for=wait_for, truncate=truncate, order_amount=order_amount, order_target_position=order_target_position) async def _get_order_percents(context, order_amount, order_target_position, input_side, symbol): order_pf_percent = None if order_amount is not None: quantity_type, quantity = script_keywords.parse_quantity(order_amount) if quantity_type in (script_keywords.QuantityType.PERCENT, script_keywords.QuantityType.AVAILABLE_PERCENT): order_pf_percent = order_amount elif quantity_type in (script_keywords.QuantityType.DELTA, script_keywords.QuantityType.DELTA_BASE): percent = await script_keywords.get_order_size_portfolio_percent( context, quantity, input_side, symbol ) order_pf_percent = f"{float(percent)}{script_keywords.QuantityType.PERCENT.value}" else: raise trading_errors.InvalidArgumentError(f"Unsupported quantity for trading signals: {order_amount}") order_position_percent = None if order_target_position is not None: quantity_type, quantity = script_keywords.parse_quantity(order_target_position) if quantity_type in (script_keywords.QuantityType.PERCENT, script_keywords.QuantityType.AVAILABLE_PERCENT): # position out of pf % here order_pf_percent = order_target_position elif quantity_type is script_keywords.QuantityType.POSITION_PERCENT: order_position_percent = order_target_position elif quantity_type is script_keywords.QuantityType.DELTA: percent = order_target_position * exchange_private_data.open_position_size(context) \ * trading_constants.ONE_HUNDRED order_position_percent = f"{float(percent)}{script_keywords.QuantityType.POSITION_PERCENT.value}" return order_pf_percent, order_position_percent def _paired_order_is_closed(context, group): grouped_orders = [] if group is None else group.get_group_open_orders() if group is not None and grouped_orders and all(order.is_closed() for order in grouped_orders): return True for order in context.just_created_orders: if order is not None: if isinstance(order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup)\ and order.order_group == group and order.is_closed(): return True return False def _use_total_holding(order_type_name): return _is_stop_order(order_type_name) def _is_stop_order(order_type_name): return "stop" in order_type_name async def _get_order_quantity_and_side(context, order_amount, order_target_position, order_type_name, side, reduce_only, unknown_portfolio_on_creation): if order_amount is not None and order_target_position is not None: raise trading_errors.InvalidArgumentError("order_amount and order_target_position can't be " "both given as parameter") use_total_holding = _use_total_holding(order_type_name) is_stop_order = _is_stop_order(order_type_name) # size based on amount if side is not None and order_amount is not None: # side if side != trading_enums.TradeOrderSide.BUY.value and side != trading_enums.TradeOrderSide.SELL.value: # we should skip that cause of performance raise trading_errors.InvalidArgumentError( f"Side parameter needs to be {trading_enums.TradeOrderSide.BUY.value} " f"or {trading_enums.TradeOrderSide.SELL.value} for your {order_type_name}.") return await position_size.get_amount(context, order_amount, side, reduce_only, is_stop_order, use_total_holding=use_total_holding, unknown_portfolio_on_creation=unknown_portfolio_on_creation), side # size and side based on target position if order_target_position is not None: return await position_size.get_target_position(context, order_target_position, reduce_only, is_stop_order, use_total_holding=use_total_holding, unknown_portfolio_on_creation=unknown_portfolio_on_creation) raise trading_errors.InvalidArgumentError("Either use side with amount or target_position.") async def _get_order_details(context, order_type_name, side, order_offset, reduce_only, order_limit_offset): # order types order_type = None final_side = side order_price = None min_offset_val = None max_offset_val = None limit_offset_val = None trailing_method = None # normal order if order_type_name == "market": order_type = trading_enums.TraderOrderType.SELL_MARKET if side == trading_enums.TradeOrderSide.SELL.value \ else trading_enums.TraderOrderType.BUY_MARKET order_price = await script_keywords.get_price_with_offset(context, "0") final_side = None # needs to be None elif order_type_name == "limit": order_type = trading_enums.TraderOrderType.SELL_LIMIT if side == trading_enums.TradeOrderSide.SELL.value \ else trading_enums.TraderOrderType.BUY_LIMIT order_price = await script_keywords.get_price_with_offset(context, order_offset) final_side = None # needs to be None # todo post only # conditional orders # should be a real SL on the exchange short and long elif order_type_name == "stop_loss": order_type = trading_enums.TraderOrderType.STOP_LOSS final_side = trading_enums.TradeOrderSide.SELL if side == trading_enums.TradeOrderSide.SELL.value \ else trading_enums.TradeOrderSide.BUY order_price = await script_keywords.get_price_with_offset(context, order_offset) reduce_only = True # should be conditional order on the exchange elif order_type_name == "stop_market": order_type = None # todo order_price = await script_keywords.get_price_with_offset(context, order_offset) # has a trigger price and a offset where the limit gets placed when triggered - # conditional order on exchange possible? elif order_type_name == "stop_limit": order_type = None # todo order_price = await script_keywords.get_price_with_offset(context, order_offset) order_limit_offset = await script_keywords.get_price_with_offset(context, order_offset) # todo post only # trailling orders # should be a real trailing stop loss on the exchange - short and long elif order_type_name == "trailing_stop_loss": order_price = await script_keywords.get_price_with_offset(context, order_offset) order_type = None # todo reduce_only = True trailing_method = "continuous" # todo make sure order gets replaced by market if price jumped below price before order creation # todo should use trailing on exchange if available or replace order on exchange elif order_type_name == "trailing_market": order_price = await script_keywords.get_price_with_offset(context, order_offset) trailing_method = "continuous" order_type = trading_enums.TraderOrderType.TRAILING_STOP final_side = trading_enums.TradeOrderSide.SELL if side == trading_enums.TradeOrderSide.SELL.value \ else trading_enums.TradeOrderSide.BUY # todo should use trailing on exchange if available or replace order on exchange elif order_type_name == "trailing_limit": order_type = trading_enums.TraderOrderType.TRAILING_STOP_LIMIT final_side = trading_enums.TradeOrderSide.SELL if side == trading_enums.TradeOrderSide.SELL.value \ else trading_enums.TradeOrderSide.BUY trailing_method = "continuous" min_offset_val = await script_keywords.get_price_with_offset(context, order_offset) # todo If the price changes such that the order becomes more than maxOffset away from the # price, then the order will be moved to minOffset away again. max_offset_val = await script_keywords.get_price_with_offset(context, order_offset) # todo post only return order_type, order_price, final_side, reduce_only, trailing_method, \ min_offset_val, max_offset_val, order_limit_offset, limit_offset_val async def _create_order(context, symbol, order_quantity, order_price, tag, order_type_name, input_side, side, final_side, order_type, order_min_offset, max_offset_val, reduce_only, group, stop_loss_price, stop_loss_tag, stop_loss_type, stop_loss_group, take_profit_price, take_profit_tag, take_profit_type, take_profit_group, wait_for, truncate, order_amount, order_target_position): # todo handle offsets, reduce_only, post_only, orders = [] error_message = "" chained_orders_group = _get_group_or_default(context, group, stop_loss_price, take_profit_price) order_pf_percent = order_position_percent = None if basic_keywords.is_emitting_trading_signals(context): order_pf_percent, order_position_percent = await _get_order_percents(context, order_amount, order_target_position, input_side, symbol) try: fees_currency_side = None if context.exchange_manager.is_future: fees_currency_side = context.exchange_manager.exchange.get_pair_future_contract(symbol).\ get_fees_currency_side() _, _, _, current_price, symbol_market = \ await trading_personal_data.get_pre_order_data(context.exchange_manager, symbol=symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT) group_adapted_quantity = _get_group_adapted_quantity(context, group, order_type, order_quantity) for final_order_quantity, final_order_price in \ trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( group_adapted_quantity, order_price, symbol_market, truncate=truncate ): if not truncate: # ensure enough money to trade (because of upper rounding) available_acc_bal = await script_keywords.available_account_balance( context, side, use_total_holding=_use_total_holding(order_type_name), is_stop_order=_is_stop_order(order_type_name), reduce_only=reduce_only) if final_order_quantity > available_acc_bal: final_order_quantity = trading_personal_data.decimal_adapt_quantity( symbol_market, available_acc_bal, truncate=True ) created_order = trading_personal_data.create_order_instance( trader=context.trader, order_type=order_type, symbol=symbol, current_price=current_price, quantity=final_order_quantity, price=final_order_price, side=final_side, tag=tag, group=group, reduce_only=reduce_only, fees_currency_side=fees_currency_side ) if order_min_offset is not None: await created_order.set_trailing_percent(order_min_offset) if wait_for: chained_orders = await chaining.chain_order(wait_for, created_order) else: stop_loss_take_profit_quantity = final_order_quantity fees = created_order.get_computed_fee() if fees[trading_enums.FeePropertyColumns.CURRENCY.value] == created_order.quantity_currency: stop_loss_take_profit_quantity = final_order_quantity - \ fees[trading_enums.FeePropertyColumns.COST.value] stop_loss_take_profit_quantity = trading_personal_data.decimal_adapt_quantity( symbol_market, stop_loss_take_profit_quantity, truncate=True ) params = await _bundle_stop_loss_and_take_profit( context, symbol_market, fees_currency_side, created_order, stop_loss_take_profit_quantity, chained_orders_group, stop_loss_tag, stop_loss_type, stop_loss_price, stop_loss_group, take_profit_tag, take_profit_type, take_profit_price, take_profit_group, order_pf_percent, order_position_percent) chained_orders = created_order.chained_orders created_order = await context.trader.create_order(created_order, params=params) if basic_keywords.is_emitting_trading_signals(context): context.get_signal_builder().add_created_order(created_order, context.trader.exchange_manager, order_pf_percent, order_position_percent) created_chained_orders = [order for order in chained_orders if order.is_created()] # add chained order if any context.just_created_orders += created_chained_orders if wait_for: # base order to be created are actually the chained orders, return them if created orders += created_chained_orders else: # add create base order orders.append(created_order) context.just_created_orders.append(created_order) except (trading_errors.MissingFunds, trading_errors.MissingMinimalExchangeTradeVolume): error_message = "missing minimal funds" except asyncio.TimeoutError as e: error_message = f"{e} and is necessary to compute the order details" except Exception as e: error_message = f"failed to create order : {e}." context.logger.exception(e, True, f"Failed to create order : {e}.") if not orders: error_message = f"not enough funds" if error_message: context.logger.warning(f"No order created when asking for {symbol} {order_type.name} " f"with a volume of {order_quantity} on {context.exchange_manager.exchange_name}: " f"{error_message}.") return orders def _get_group_adapted_quantity(context, group, order_type, order_quantity): if isinstance(group, trading_personal_data.BalancedTakeProfitAndStopOrderGroup) and context.just_created_orders: all_take_profit = all_stop = True is_creating_stop_order = trading_personal_data.is_stop_order(order_type) for order in context.just_created_orders: if order.order_group == group: if trading_personal_data.is_stop_order(order.order_type): all_take_profit = False else: all_stop = False if (is_creating_stop_order and all_stop) or (not is_creating_stop_order and all_take_profit): # we are only creating stop / take profit orders, no need to balance return order_quantity # we are now adding the order side of the orders, we need to balance if group.can_create_order(order_type, order_quantity): return order_quantity return group.get_max_order_quantity(order_type) return order_quantity def _get_group_or_default(context, group, stop_loss_price, take_profit_price): if stop_loss_price is not None or take_profit_price is not None: # orders have to be bundled together, group them if group is None: # use balanced group by default return grouping.create_balanced_take_profit_and_stop_group(context) else: return group return group async def _bundle_stop_loss_and_take_profit( context, symbol_market, fees_currency_side, order, quantity, default_group, stop_loss_tag, stop_loss_type, stop_loss_price, stop_loss_group, take_profit_tag, take_profit_type, take_profit_price, take_profit_group, order_pf_percent, order_position_percent) -> dict: params = {} side = trading_enums.TradeOrderSide.SELL if order.side is trading_enums.TradeOrderSide.BUY \ else trading_enums.TradeOrderSide.BUY order_kwargs = { "fees_currency_side": fees_currency_side, "reduce_only": True } if stop_loss_price is not None: order_type = stop_loss_type if stop_loss_type else trading_enums.TraderOrderType.STOP_LOSS params.update( await _bundle_chained_order(context, symbol_market, order, quantity, default_group, side, order_kwargs, stop_loss_tag, order_type, stop_loss_price, stop_loss_group, order_pf_percent, order_position_percent) ) if take_profit_price is not None: if take_profit_type: order_type = take_profit_type else: order_type = trading_enums.TraderOrderType.BUY_LIMIT if side is trading_enums.TradeOrderSide.BUY \ else trading_enums.TraderOrderType.SELL_LIMIT params.update( await _bundle_chained_order(context, symbol_market, order, quantity, default_group, None, order_kwargs, take_profit_tag, order_type, take_profit_price, take_profit_group, order_pf_percent, order_position_percent) ) return params async def _bundle_chained_order(context, symbol_market, order, quantity, default_group, side, order_kwargs, tag, order_type, price, group, order_pf_percent, order_position_percent) -> dict: adapted_price = trading_personal_data.decimal_adapt_price(symbol_market, price) group = default_group if group is None else group chained_order = trading_personal_data.create_order_instance( trader=context.trader, order_type=order_type, symbol=order.symbol, current_price=order.created_last_price, quantity=quantity, price=adapted_price, side=side, tag=tag, group=group, **order_kwargs ) params = await context.trader.bundle_chained_order_with_uncreated_order( order, chained_order, chained_order.update_with_triggering_order_fees ) if basic_keywords.is_emitting_trading_signals(context): context.get_signal_builder().add_created_order(chained_order, context.trader.exchange_manager, order_pf_percent, order_position_percent) return params ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/limit_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order async def limit( context, side=None, symbol=None, amount=None, target_position=None, offset=None, stop_loss_offset=None, stop_loss_tag=None, stop_loss_type=None, stop_loss_group=None, take_profit_offset=None, take_profit_tag=None, take_profit_type=None, take_profit_group=None, slippage_limit=None, time_limit=None, reduce_only=False, post_only=False, tag=None, group=None, wait_for=None ): return await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount, order_target_position=target_position, stop_loss_offset=stop_loss_offset, stop_loss_tag=stop_loss_tag, stop_loss_type=stop_loss_type, stop_loss_group=stop_loss_group, take_profit_offset=take_profit_offset, take_profit_tag=take_profit_tag, take_profit_type=take_profit_type, take_profit_group=take_profit_group, order_type_name="limit", order_offset=offset, slippage_limit=slippage_limit, time_limit=time_limit, reduce_only=reduce_only, post_only=post_only, tag=tag, group=group, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/market_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order async def market( context, side=None, symbol=None, amount=None, target_position=None, stop_loss_offset=None, stop_loss_tag=None, stop_loss_type=None, stop_loss_group=None, take_profit_offset=None, take_profit_tag=None, take_profit_type=None, take_profit_group=None, reduce_only=False, tag=None, group=None, wait_for=None ): return await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount, order_target_position=target_position, stop_loss_offset=stop_loss_offset, stop_loss_tag=stop_loss_tag, stop_loss_type=stop_loss_type, stop_loss_group=stop_loss_group, take_profit_offset=take_profit_offset, take_profit_tag=take_profit_tag, take_profit_type=take_profit_type, take_profit_group=take_profit_group, order_type_name="market", reduce_only=reduce_only, tag=tag, group=group, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/scaled_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.position_size as position_size import octobot_trading.modes.script_keywords as script_keywords async def scaled_limit( context, side=None, symbol=None, order_type_name="limit", scale_from=None, scale_to=None, order_count=10, distribution="linear", amount=None, target_position=None, stop_loss_offset=None, stop_loss_tag=None, stop_loss_type=None, stop_loss_group=None, take_profit_offset=None, take_profit_tag=None, take_profit_type=None, take_profit_group=None, slippage_limit=None, time_limit=None, reduce_only=False, post_only=False, tag=None, group=None, wait_for=None ): amount_per_order = None unknown_portfolio_on_creation = wait_for is not None if target_position is None and amount is not None: amount_per_order = await position_size. \ get_amount(context, amount, side=side, use_total_holding=True, unknown_portfolio_on_creation=unknown_portfolio_on_creation) / order_count elif target_position is not None and amount is None and side is None: total_amount, side = await position_size.get_target_position( context, target_position, reduce_only=reduce_only, unknown_portfolio_on_creation=unknown_portfolio_on_creation) amount_per_order = total_amount / order_count else: raise RuntimeError("Either use side with amount or target_position for scaled orders.") scale_from_price = await script_keywords.get_price_with_offset(context, scale_from, side=side) scale_to_price = await script_keywords.get_price_with_offset(context, scale_to, side=side) order_prices = [] if distribution == "linear": if scale_from_price >= scale_to_price: price_difference = scale_from_price - scale_to_price step_size = price_difference / (order_count - 1) for i in range(0, order_count): order_prices.append(scale_from_price - (step_size * i)) elif scale_to_price > scale_from_price: price_difference = scale_to_price - scale_from_price step_size = price_difference / (order_count - 1) for i in range(0, order_count): order_prices.append(scale_from_price + (step_size * i)) else: raise RuntimeError("scaled order: unsupported distribution type. check the documentation for more informations") created_orders = [] for order_price in order_prices: new_created_order = await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount_per_order, order_type_name="limit", order_offset=f"@{order_price}", stop_loss_offset=stop_loss_offset, stop_loss_tag=stop_loss_tag, stop_loss_type=stop_loss_type, stop_loss_group=stop_loss_group, take_profit_offset=take_profit_offset, take_profit_tag=take_profit_tag, take_profit_type=take_profit_type, take_profit_group=take_profit_group, slippage_limit=slippage_limit, time_limit=time_limit, reduce_only=reduce_only, post_only=post_only, group=group, tag=tag, wait_for=wait_for ) try: created_orders.append(new_created_order[0]) except IndexError: pass # raise RuntimeError(f"scaled {side} order not created") return created_orders async def scaled_stop_loss( context, side=None, symbol=None, scale_from=None, scale_to=None, order_count=10, distribution="linear", amount=None, target_position=None, slippage_limit=None, time_limit=None, tag=None, group=None, wait_for=None ): await scaled_limit(context, side=side, symbol=symbol, order_type_name="stop_loss", scale_from=scale_from, scale_to=scale_to, order_count=order_count, distribution=distribution, amount=amount, target_position=target_position, slippage_limit=slippage_limit, time_limit=time_limit, reduce_only=True, tag=tag, group=group, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/stop_loss_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order async def stop_loss( context, side=None, symbol=None, offset=None, amount=None, target_position=None, tag=None, group=None, wait_for=None ): return await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount, order_target_position=target_position, order_type_name="stop_loss", order_offset=offset, reduce_only=True, tag=tag, group=group, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/trailing_limit_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order async def trailing_limit( context, side=None, symbol=None, amount=None, target_position=None, offset=None, min_offset=None, max_offset=None, slippage_limit=None, time_limit=None, reduce_only=False, post_only=False, tag=None, group=None, wait_for=None ): return await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount, order_target_position=target_position, order_type_name="trailing_limit", order_min_offset=min_offset, order_max_offset=max_offset, order_offset=offset, slippage_limit=slippage_limit, time_limit=time_limit, reduce_only=reduce_only, post_only=post_only, group=group, tag=tag, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/trailing_market_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order async def trailing_market( context, side=None, symbol=None, amount=None, target_position=None, offset=None, reduce_only=False, tag=None, group=None, wait_for=None ): return await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount, order_target_position=target_position, order_type_name="trailing_market", order_offset=offset, reduce_only=reduce_only, tag=tag, group=group, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/order_types/trailing_stop_loss_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order async def trailing_stop_loss( context, side=None, symbol=None, amount=None, target_position=None, offset=None, reduce_only=True, tag=None, group=None, wait_for=None ) -> list: return await create_order.create_order_instance( context, side=side, symbol=symbol or context.symbol, order_amount=amount, order_target_position=target_position, order_type_name="trailing_stop_loss", order_offset=offset, reduce_only=reduce_only, tag=tag, group=group, wait_for=wait_for ) ================================================ FILE: Meta/Keywords/scripting_library/orders/position_size/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .amount import * from .target_position import * ================================================ FILE: Meta/Keywords/scripting_library/orders/position_size/amount.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.modes.script_keywords as script_keywords import octobot_trading.errors as trading_errors import octobot_trading.enums as trading_enums import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_private_data as exchange_private_data import octobot_commons.constants as commons_constants async def get_amount( context=None, input_amount=None, side=trading_enums.TradeOrderSide.BUY.value, reduce_only=True, is_stop_order=False, use_total_holding=False, unknown_portfolio_on_creation=False, target_price=None ): amount_value = await script_keywords.get_amount_from_input_amount( context=context, input_amount=input_amount, side=side, reduce_only=reduce_only, is_stop_order=is_stop_order, use_total_holding=use_total_holding, target_price=target_price ) if unknown_portfolio_on_creation: # no way to check if the amount is valid when creating order _, amount_value = script_keywords.parse_quantity(input_amount) return amount_value ================================================ FILE: Meta/Keywords/scripting_library/orders/position_size/target_position.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.errors as trading_errors import octobot_trading.modes.script_keywords as script_keywords import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_private_data as exchange_private_data # todo handle negative open position for shorts async def get_target_position( context=None, target=None, reduce_only=True, is_stop_order=False, use_total_holding=False, unknown_portfolio_on_creation=False, target_price=None ): target_position_type, target_position_value = script_keywords.parse_quantity(target) if target_position_type is script_keywords.QuantityType.POSITION_PERCENT: open_position_size_val = exchange_private_data.open_position_size(context) target_size = open_position_size_val * target_position_value / 100 order_size = target_size - open_position_size_val elif target_position_type is script_keywords.QuantityType.PERCENT: total_acc_bal = await script_keywords.total_account_balance(context) target_size = total_acc_bal * target_position_value / 100 order_size = target_size - exchange_private_data.open_position_size(context) # in target position, we always provide the position size we want to end up with elif target_position_type in (script_keywords.QuantityType.DELTA, script_keywords.QuantityType.DELTA_BASE) \ or target_position_type is script_keywords.QuantityType.FLAT: order_size = target_position_value - exchange_private_data.open_position_size(context) if target == order_size: # no order to create return trading_constants.ZERO, trading_enums.TradeOrderSide.BUY.value elif target_position_type is script_keywords.QuantityType.AVAILABLE_PERCENT: available_account_balance_val = await script_keywords.available_account_balance(context, reduce_only=reduce_only) order_size = available_account_balance_val * target_position_value / 100 else: raise trading_errors.InvalidArgumentError("make sure to use a supported syntax for position") side = get_target_position_side(order_size) if side == trading_enums.TradeOrderSide.SELL.value: order_size = order_size * -1 if not unknown_portfolio_on_creation: order_size = await script_keywords.adapt_amount_to_holdings(context, order_size, side, use_total_holding, reduce_only, is_stop_order, target_price=target_price) return order_size, side def get_target_position_side(order_size): if order_size < 0: return trading_enums.TradeOrderSide.SELL.value elif order_size > 0: return trading_enums.TradeOrderSide.BUY.value # order_size == 0 raise RuntimeError("Computed position size is 0") ================================================ FILE: Meta/Keywords/scripting_library/orders/waiting.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import time import tentacles.Meta.Keywords.scripting_library.orders.open_orders as open_orders import octobot_trading.personal_data as personal_data import octobot_commons.logging as logging async def wait_for_orders_close(ctx, orders, timeout=None): if not isinstance(orders, list): orders = [orders] t0 = time.time() refresh_interval = 0.01 # wait for orders to be filled or cancelled # also wait for associated chained orders to be opened try: # order.is_closed() fails when order got filled meanwhile while not all(order.is_closed() for order in orders) or \ not are_all_chained_orders_created(ctx, orders): if timeout is None or time.time() - t0 < timeout: if ctx.exchange_manager.is_backtesting: raise asyncio.TimeoutError("Can't wait for orders in backtesting") await asyncio.sleep(refresh_interval) else: raise asyncio.TimeoutError("Order wasnt not filled in time") except AttributeError as e: logging.get_logger("Waiting").exception(e, True, "AttributeError on checking orders (should not happen)") pass # continue try to create take profit in case of connection issues def are_all_chained_orders_created(ctx, orders): for order in orders: for chained_order in order.chained_orders: if not chained_order.is_created(): return False if chained_order.is_closed(): continue found_order = False # ensure that chained orders are open or got closed for open_order in open_orders.get_open_orders(ctx): if personal_data.is_associated_pending_order(open_order, chained_order): found_order = True break if not found_order: return False return True async def wait_for_stop_loss_open(ctx, order_tag=None, order_group=None, timeout=60): """ waits for and finds a stop order based on order tag or order group :param ctx: :param order_tag: :param order_group: :param timeout: in seconds :return: the stop loss order """ t0 = time.time() refresh_interval = 0.01 orders = ctx.exchange_manager.exchange_personal_data.orders_manager.orders stop_found = False while not stop_found: for order in orders: stop_found = orders[order].tag == order_tag or orders[order].order_group == order_group if stop_found: return orders[order] if timeout is None or time.time() - t0 < timeout: if ctx.exchange_manager.is_backtesting: raise asyncio.TimeoutError("Can't wait for orders in backtesting") await asyncio.sleep(refresh_interval) else: ctx.logger.error("Stop Loss Order was not found: was not placed in time or got already triggered") return None ================================================ FILE: Meta/Keywords/scripting_library/settings/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .script_settings import * ================================================ FILE: Meta/Keywords/scripting_library/settings/script_settings.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.api as trading_api import octobot_commons.errors as errors def set_minimum_candles(context, candles_count): available_candles = 0 try: available_candles = trading_api.get_symbol_candles_count( trading_api.get_symbol_data(context.exchange_manager, context.symbol, allow_creation=False), context.time_frame ) if available_candles >= candles_count: return except KeyError: pass raise errors.MissingDataError(f"Missing candles: available: {available_candles}, required: {candles_count}") def do_not_initialize(): raise errors.MissingDataError("Script should not be considered initialized (do_not_initialize call)") def set_allow_artificial_orders(context, allow_artificial_orders): context.allow_artificial_orders = allow_artificial_orders context.exchange_manager.trader.allow_artificial_orders = context.allow_artificial_orders ================================================ FILE: Meta/Keywords/scripting_library/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import mock import decimal import sys import asyncio import octobot_commons.asyncio_tools as asyncio_tools import octobot_trading.modes.script_keywords.context_management as context_management import octobot_trading.exchanges as trading_exchanges import octobot_trading.enums as enums @pytest.fixture def null_context(): context = context_management.Context( None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, ) yield context @pytest_asyncio.fixture async def mock_context(backtesting_trader): _, exchange_manager, trader_inst = backtesting_trader context = context_management.Context( mock.Mock(), exchange_manager, trader_inst, mock.Mock(), "BTC/USDT", mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), ) context.signal_builder = mock.Mock() context.is_trading_signal_emitter = mock.Mock(return_value=False) context.orders_writer = mock.Mock(log_many=mock.AsyncMock()) portfolio_manager = exchange_manager.exchange_personal_data.portfolio_manager # init portfolio with 0.5 BTC, 20 ETH and 30000 USDT and only 0.1 available BTC portfolio_manager.portfolio.update_portfolio_from_balance({ 'BTC': {'available': decimal.Decimal("0.1"), 'total': decimal.Decimal("0.5")}, 'ETH': {'available': decimal.Decimal("20"), 'total': decimal.Decimal("20")}, 'USDT': {'available': decimal.Decimal("30000"), 'total': decimal.Decimal("30000")} }, True) exchange_manager.client_symbols.append("BTC/USDT") exchange_manager.client_symbols.append("ETH/USDT") exchange_manager.client_symbols.append("ETH/BTC") # init prices with BTC/USDT = 40000, ETH/BTC = 0.1 and ETH/USDT = 4000 portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair["BTC/USDT"] = \ decimal.Decimal("40000") portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair["ETH/USDT"] = \ decimal.Decimal("4000") portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair["ETH/BTC"] = \ decimal.Decimal("0.1") portfolio_manager.handle_balance_updated() yield context @pytest.fixture def symbol_market(): return { enums.ExchangeConstantsMarketStatusColumns.LIMITS.value: { enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT.value: { enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT_MIN.value: 0.5, enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT_MAX.value: 100, }, enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST.value: { enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST_MIN.value: 1, enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST_MAX.value: 200 }, enums.ExchangeConstantsMarketStatusColumns.LIMITS_PRICE.value: { enums.ExchangeConstantsMarketStatusColumns.LIMITS_PRICE_MIN.value: 0.5, enums.ExchangeConstantsMarketStatusColumns.LIMITS_PRICE_MAX.value: 50 }, }, enums.ExchangeConstantsMarketStatusColumns.PRECISION.value: { enums.ExchangeConstantsMarketStatusColumns.PRECISION_PRICE.value: 8, enums.ExchangeConstantsMarketStatusColumns.PRECISION_AMOUNT.value: 8 } } @pytest.fixture def event_loop(): # re-configure async loop each time this fixture is called _configure_async_test_loop() loop = asyncio.new_event_loop() # use ErrorContainer to catch otherwise hidden exceptions occurring in async scheduled tasks error_container = asyncio_tools.ErrorContainer() loop.set_exception_handler(error_container.exception_handler) yield loop # will fail if exceptions have been silently raised loop.run_until_complete(error_container.check()) loop.close() @pytest.fixture def skip_if_octobot_trading_mocking_disabled(request): try: with mock.patch.object(trading_exchanges.Trader, "cancel_order", mock.AsyncMock()): pass # mocking is available except TypeError: pytest.skip(reason=f"Disabled {request.node.name} [OctoBot-Trading mocks not allowed]") def _configure_async_test_loop(): if sys.version_info[0] == 3 and sys.version_info[1] >= 8 and sys.platform.startswith('win'): # use WindowsSelectorEventLoopPolicy to avoid aiohttp connexion close warnings # https://github.com/encode/httpx/issues/914#issuecomment-622586610 asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # set default values for async loop _configure_async_test_loop() ================================================ FILE: Meta/Keywords/scripting_library/tests/backtesting/__init__.py ================================================ ================================================ FILE: Meta/Keywords/scripting_library/tests/backtesting/data_store.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_trading.enums as trading_enums import octobot_commons.enums as commons_enums @pytest.fixture def default_price_data(): # imported from real backtesting data return { "BTC/USDT": [[1606780800000.0, 19695.87, 19720.0, 19479.8, 19565.47, 4570.361518], [1606784400000.0, 19565.47, 19639.99, 19433.15, 19605.75, 2702.459235], [1606788000000.0, 19605.75, 19704.93, 19548.57, 19680.95, 2408.229978], [1606791600000.0, 19680.96, 19682.77, 19340.0, 19419.74, 2889.848604], [1606795200000.0, 19419.73, 19527.02, 19344.92, 19354.31, 3400.857941], [1606798800000.0, 19352.64, 19502.54, 19281.38, 19483.73, 2620.883792], [1606802400000.0, 19483.73, 19517.94, 19309.87, 19338.34, 3129.776329], [1606806000000.0, 19338.33, 19546.81, 19300.0, 19515.63, 3009.225182], [1606809600000.0, 19515.62, 19567.0, 19441.19, 19466.99, 3143.172961], [1606813200000.0, 19467.0, 19570.0, 19426.96, 19565.0, 2824.268695], [1606816800000.0, 19564.99, 19800.0, 19558.77, 19739.51, 7640.260767], [1606820400000.0, 19739.51, 19888.0, 18886.0, 19425.0, 14556.657151], [1606824000000.0, 19425.4, 19482.01, 18399.99, 18551.35, 17554.24707], [1606827600000.0, 18550.25, 18844.15, 18001.12, 18759.73, 14772.777639], [1606831200000.0, 18759.74, 19364.82, 18651.0, 19285.31, 7821.047189], [1606834800000.0, 19285.3, 19489.3, 19147.6, 19263.37, 6838.162713], [1606838400000.0, 19263.36, 19325.83, 18938.22, 19058.8, 5690.017039], [1606842000000.0, 19058.8, 19086.95, 18611.88, 19058.4, 6526.028983], [1606845600000.0, 19058.4, 19074.64, 18693.37, 18738.82, 3316.505911], [1606849200000.0, 18738.83, 19135.19, 18720.48, 19069.79, 2954.651375], [1606852800000.0, 19069.79, 19150.0, 18862.0, 19024.32, 2451.419624], [1606856400000.0, 19024.33, 19211.0, 18936.61, 19038.39, 2275.27857], [1606860000000.0, 19038.39, 19156.72, 18830.18, 18895.0, 1719.522796], [1606863600000.0, 18895.01, 18943.26, 18725.0, 18764.96, 2883.10159], [1606867200000.0, 18764.96, 18877.92, 18433.0, 18836.51, 4372.162317], [1606870800000.0, 18836.5, 18972.12, 18703.0, 18854.01, 2504.979019], [1606874400000.0, 18854.01, 18863.73, 18639.86, 18676.4, 1647.545452], [1606878000000.0, 18676.39, 18788.32, 18506.16, 18618.25, 3284.666149], [1606881600000.0, 18618.0, 18700.0, 18330.0, 18550.96, 4330.094724], [1606885200000.0, 18550.95, 18660.0, 18465.42, 18654.41, 2661.915712], [1606888800000.0, 18654.27, 18939.97, 18630.63, 18926.66, 4068.001777], [1606892400000.0, 18924.13, 19135.0, 18833.0, 19124.48, 4325.750236], [1606896000000.0, 19124.49, 19342.0, 19059.09, 19195.19, 4980.517871], [1606899600000.0, 19195.52, 19319.67, 18977.03, 19035.99, 3695.078977], [1606903200000.0, 19036.29, 19196.63, 18991.43, 19103.39, 3344.073936], [1606906800000.0, 19103.38, 19199.0, 19022.47, 19127.31, 2688.864019], [1606910400000.0, 19127.31, 19129.13, 18792.31, 18940.98, 4128.997824], [1606914000000.0, 18940.97, 19250.7, 18919.31, 19124.51, 3955.239884], [1606917600000.0, 19126.75, 19175.0, 18850.0, 18910.21, 4219.856604], [1606921200000.0, 18910.21, 18989.0, 18728.38, 18891.57, 3966.239422], [1606924800000.0, 18891.57, 19015.7, 18770.0, 18856.25, 3304.383039], [1606928400000.0, 18856.25, 18999.0, 18810.27, 18976.33, 2378.363036], [1606932000000.0, 18976.33, 19068.0, 18894.8, 19011.99, 2505.872597], [1606935600000.0, 19011.97, 19139.06, 18964.08, 19101.1, 2128.498473], [1606939200000.0, 19101.1, 19150.0, 19044.85, 19083.4, 2075.778464], [1606942800000.0, 19083.77, 19168.91, 19046.3, 19145.01, 1486.371573], [1606946400000.0, 19145.0, 19235.0, 19099.0, 19111.13, 1845.354603], [1606950000000.0, 19111.13, 19260.0, 19089.5, 19204.09, 2012.40777], [1606953600000.0, 19204.08, 19299.0, 19150.76, 19180.0, 2131.560886], [1606957200000.0, 19180.0, 19184.54, 18940.0, 19016.91, 2664.789937], [1606960800000.0, 19016.92, 19099.05, 18945.0, 19041.73, 2011.154103], [1606964400000.0, 19041.73, 19178.87, 19013.17, 19087.0, 1658.093318], [1606968000000.0, 19087.01, 19124.03, 19022.22, 19041.21, 1398.143328], [1606971600000.0, 19041.21, 19110.0, 18867.2, 18922.83, 2207.439489], [1606975200000.0, 18922.83, 19015.42, 18880.51, 18970.24, 1915.073941], [1606978800000.0, 18970.24, 19250.0, 18911.0, 19208.11, 3187.186407], [1606982400000.0, 19208.11, 19450.0, 19161.85, 19378.79, 6548.804707], [1606986000000.0, 19378.79, 19420.96, 19097.6, 19363.99, 4473.862826], [1606989600000.0, 19363.98, 19422.9, 19244.75, 19353.69, 3404.982735], [1606993200000.0, 19353.69, 19444.4, 19290.0, 19396.5, 3752.006358], [1606996800000.0, 19396.49, 19425.0, 19219.78, 19321.26, 3073.255484], [1607000400000.0, 19321.26, 19375.16, 19252.2, 19281.0, 2397.631645], [1607004000000.0, 19281.51, 19397.0, 19274.0, 19351.32, 2196.785603], [1607007600000.0, 19351.32, 19536.2, 19300.0, 19535.0, 4242.672379], [1607011200000.0, 19535.0, 19598.0, 19251.76, 19354.78, 5994.113495], [1607014800000.0, 19354.78, 19384.23, 19194.06, 19299.23, 2612.551421], [1607018400000.0, 19299.24, 19398.9, 19299.24, 19371.46, 2030.270253], [1607022000000.0, 19371.46, 19422.9, 19328.51, 19414.29, 1747.888777], [1607025600000.0, 19414.29, 19435.29, 19306.27, 19369.44, 1970.71359], [1607029200000.0, 19369.44, 19462.24, 19328.92, 19438.86, 1585.05878], [1607032800000.0, 19438.86, 19479.71, 19402.0, 19464.11, 1224.310923], [1607036400000.0, 19464.12, 19540.0, 19402.11, 19421.9, 2261.040894], [1607040000000.0, 19422.34, 19527.0, 19378.92, 19460.65, 2083.900225], [1607043600000.0, 19462.49, 19489.84, 19319.39, 19321.42, 1937.056634], [1607047200000.0, 19323.31, 19375.0, 19250.0, 19251.92, 2016.847648], [1607050800000.0, 19251.92, 19323.31, 19122.74, 19162.62, 2645.78391], [1607054400000.0, 19162.62, 19318.83, 19122.48, 19286.78, 2332.879073], [1607058000000.0, 19286.78, 19312.79, 19190.52, 19200.07, 1607.201706], [1607061600000.0, 19200.01, 19367.05, 19192.89, 19317.13, 1791.575063], [1607065200000.0, 19317.13, 19335.83, 19238.31, 19283.94, 2191.960974], [1607068800000.0, 19283.94, 19447.14, 19281.23, 19388.89, 3152.638117], [1607072400000.0, 19388.9, 19410.49, 19316.11, 19354.23, 1738.767103], [1607076000000.0, 19354.23, 19360.73, 18900.0, 18978.35, 9630.663093], [1607079600000.0, 18978.35, 19077.16, 18700.0, 18833.25, 5226.950061], [1607083200000.0, 18834.48, 19029.56, 18686.38, 19026.49, 6651.625618], [1607086800000.0, 19026.49, 19027.0, 18914.42, 19005.34, 2476.86035], [1607090400000.0, 19005.34, 19146.22, 18917.84, 19046.11, 3301.095255], [1607094000000.0, 19046.11, 19073.46, 18938.25, 18943.35, 2288.005452], [1607097600000.0, 18944.06, 18991.7, 18817.0, 18981.28, 3493.465697], [1607101200000.0, 18981.28, 19029.2, 18932.23, 18968.93, 1898.7485], [1607104800000.0, 18968.82, 19078.68, 18929.16, 19056.45, 1718.020232], [1607108400000.0, 19056.45, 19065.0, 19000.0, 19038.73, 1689.617236], [1607112000000.0, 19038.73, 19045.34, 18880.0, 18962.53, 2390.289129], [1607115600000.0, 18962.52, 18988.75, 18725.6, 18806.41, 3265.941089], [1607119200000.0, 18807.09, 18875.27, 18565.31, 18665.3, 2805.203431], [1607122800000.0, 18665.31, 18841.0, 18601.5, 18650.52, 2948.572604], [1607126400000.0, 18650.51, 18791.53, 18500.0, 18764.23, 4398.592542], [1607130000000.0, 18764.23, 18819.83, 18634.5, 18644.89, 2253.931869], [1607133600000.0, 18644.88, 18848.62, 18641.1, 18789.66, 2388.321713], [1607137200000.0, 18789.65, 18880.0, 18738.34, 18818.85, 2317.888684], [1607140800000.0, 18818.05, 18932.0, 18800.0, 18863.9, 1551.894666], [1607144400000.0, 18863.9, 18970.0, 18840.69, 18954.42, 1647.778313], [1607148000000.0, 18955.83, 18990.08, 18906.16, 18963.03, 1590.750531], [1607151600000.0, 18963.03, 18986.61, 18900.0, 18911.31, 1514.204824], [1607155200000.0, 18911.3, 19177.0, 18911.3, 19149.9, 3390.549236], [1607158800000.0, 19149.9, 19165.0, 19053.05, 19110.42, 1671.420619], [1607162400000.0, 19110.42, 19137.28, 19037.5, 19114.48, 1404.535637], [1607166000000.0, 19114.48, 19127.46, 18959.03, 19013.43, 2031.724234], [1607169600000.0, 19013.43, 19080.01, 18965.45, 19060.01, 1505.479033], [1607173200000.0, 19060.27, 19069.9, 18941.74, 18983.13, 1663.18758], [1607176800000.0, 18982.57, 19092.03, 18963.83, 19080.01, 2031.09956], [1607180400000.0, 19080.01, 19150.0, 19051.0, 19110.78, 1878.881403], [1607184000000.0, 19111.13, 19149.92, 19044.28, 19099.0, 1712.332099], [1607187600000.0, 19098.99, 19125.76, 19026.0, 19125.76, 1130.566015], [1607191200000.0, 19125.77, 19140.0, 19085.0, 19116.3, 1113.481955], [1607194800000.0, 19116.65, 19172.37, 19075.3, 19119.19, 1273.081414], [1607198400000.0, 19119.19, 19140.0, 18993.92, 19054.9, 1291.846074], [1607202000000.0, 19055.24, 19110.0, 19000.0, 19007.04, 1050.880547], [1607205600000.0, 19007.04, 19052.17, 18977.16, 19020.5, 772.952693], [1607209200000.0, 19020.59, 19157.52, 19020.08, 19147.66, 1337.367332], [1607212800000.0, 19147.66, 19277.0, 19131.02, 19268.81, 2729.198654], [1607216400000.0, 19268.81, 19342.0, 19228.0, 19261.52, 2377.439344], [1607220000000.0, 19261.51, 19288.06, 19206.03, 19233.43, 1450.02811], [1607223600000.0, 19233.44, 19245.91, 19137.18, 19183.39, 1223.127577], [1607227200000.0, 19183.39, 19231.08, 19155.04, 19185.92, 881.834286], [1607230800000.0, 19185.92, 19222.54, 19105.0, 19151.97, 1054.988231], [1607234400000.0, 19152.0, 19224.66, 19133.5, 19183.94, 845.302981], [1607238000000.0, 19184.23, 19260.0, 19172.3, 19230.99, 921.805973], [1607241600000.0, 19230.99, 19251.0, 19003.64, 19017.56, 2003.080539], [1607245200000.0, 19017.54, 19083.98, 18950.87, 19042.22, 2087.157592], [1607248800000.0, 19042.23, 19061.53, 18957.04, 19041.31, 1124.016156], [1607252400000.0, 19041.32, 19100.0, 18959.73, 19089.16, 1115.628806], [1607256000000.0, 19089.15, 19089.16, 18960.55, 18982.47, 1111.226328], [1607259600000.0, 18982.87, 19021.0, 18857.0, 18953.07, 2100.601588], [1607263200000.0, 18953.07, 19131.53, 18930.98, 19120.0, 1809.57926], [1607266800000.0, 19120.27, 19152.38, 19094.0, 19125.83, 1350.286034], [1607270400000.0, 19125.84, 19213.09, 19018.0, 19106.8, 1927.444027], [1607274000000.0, 19106.46, 19177.16, 19065.91, 19128.02, 920.596848], [1607277600000.0, 19127.86, 19174.36, 19093.78, 19105.21, 870.086461], [1607281200000.0, 19105.2, 19156.96, 19076.47, 19132.62, 870.441944], [1607284800000.0, 19132.62, 19184.62, 19123.41, 19168.73, 868.814714], [1607288400000.0, 19168.73, 19244.0, 19114.01, 19238.08, 1606.570085], [1607292000000.0, 19238.08, 19249.0, 19045.0, 19125.44, 1481.428442], [1607295600000.0, 19125.55, 19420.0, 19125.55, 19359.4, 4312.407881], [1607299200000.0, 19358.67, 19420.91, 19288.23, 19318.56, 1983.516535], [1607302800000.0, 19318.56, 19349.55, 19219.99, 19293.08, 1527.856348], [1607306400000.0, 19293.09, 19307.49, 19169.35, 19200.0, 1548.269166], [1607310000000.0, 19200.01, 19288.23, 19157.55, 19282.68, 1339.849597], [1607313600000.0, 19282.68, 19306.11, 19233.36, 19285.97, 1193.985926], [1607317200000.0, 19285.97, 19299.0, 19240.0, 19246.26, 1023.882776], [1607320800000.0, 19246.25, 19356.3, 19237.37, 19306.36, 1454.812033], [1607324400000.0, 19306.36, 19399.0, 19293.93, 19371.3, 1503.212608], [1607328000000.0, 19371.24, 19386.15, 19200.0, 19250.84, 1808.057972], [1607331600000.0, 19250.85, 19256.62, 19177.99, 19213.34, 1678.482081], [1607335200000.0, 19213.34, 19254.06, 19147.05, 19183.41, 1839.598649], [1607338800000.0, 19183.41, 19231.22, 19090.0, 19103.58, 1914.160878], [1607342400000.0, 19103.79, 19239.0, 19097.5, 19238.8, 2301.613075], [1607346000000.0, 19239.0, 19253.09, 19180.7, 19187.67, 1522.342358], [1607349600000.0, 19187.9, 19234.61, 19095.72, 19195.84, 2077.875894], [1607353200000.0, 19195.84, 19257.03, 19168.88, 19213.9, 1660.035444], [1607356800000.0, 19213.9, 19241.39, 19166.79, 19188.36, 1587.897438], [1607360400000.0, 19188.29, 19210.09, 19139.99, 19160.01, 1440.248622], [1607364000000.0, 19160.01, 19188.16, 18902.88, 18939.7, 4582.186702], [1607367600000.0, 18938.51, 19039.02, 18912.31, 18967.01, 1898.119135], [1607371200000.0, 18967.01, 19059.8, 18935.06, 19050.63, 1533.444692], [1607374800000.0, 19050.63, 19115.28, 19015.36, 19075.41, 1561.729683], [1607378400000.0, 19075.45, 19115.78, 19057.86, 19114.48, 788.101718], [1607382000000.0, 19114.49, 19217.64, 19072.8, 19166.9, 1603.016963], [1607385600000.0, 19166.9, 19229.99, 19132.67, 19228.4, 1539.700738], [1607389200000.0, 19228.41, 19235.6, 19150.25, 19210.62, 1465.000464], [1607392800000.0, 19210.63, 19215.34, 19160.0, 19177.61, 1061.470891], [1607396400000.0, 19177.61, 19196.0, 19132.78, 19154.73, 1086.763535], [1607400000000.0, 19154.73, 19210.0, 19151.96, 19181.21, 1423.218666], [1607403600000.0, 19181.21, 19293.76, 19181.21, 19293.76, 1362.402648], [1607407200000.0, 19293.76, 19294.84, 19091.0, 19160.49, 1931.913758], [1607410800000.0, 19160.49, 19199.62, 19135.0, 19143.1, 1877.753232], [1607414400000.0, 19143.1, 19168.88, 19010.0, 19069.96, 2558.836053], [1607418000000.0, 19069.95, 19101.59, 18700.0, 18791.77, 5751.663474], [1607421600000.0, 18791.78, 18879.0, 18700.04, 18816.0, 3764.950593], [1607425200000.0, 18816.0, 18869.23, 18730.0, 18762.96, 2445.010049], [1607428800000.0, 18762.95, 18864.06, 18610.0, 18726.93, 4536.598736], [1607432400000.0, 18726.92, 18942.3, 18726.92, 18922.84, 3398.709741], [1607436000000.0, 18923.42, 18974.83, 18861.47, 18865.01, 2255.911886], [1607439600000.0, 18865.0, 18913.14, 18780.0, 18809.91, 2017.421275], [1607443200000.0, 18809.91, 18910.0, 18745.31, 18832.03, 2390.26467], [1607446800000.0, 18832.02, 18937.81, 18818.0, 18927.41, 1365.998541], [1607450400000.0, 18927.41, 18930.84, 18826.08, 18831.0, 1791.746261], [1607454000000.0, 18831.0, 18895.0, 18787.87, 18795.74, 1381.006558], [1607457600000.0, 18795.74, 18848.0, 18664.51, 18742.63, 3177.566538], [1607461200000.0, 18742.64, 18827.34, 18687.8, 18777.86, 1763.504325], [1607464800000.0, 18778.02, 18837.45, 18320.0, 18340.01, 4197.202626], [1607468400000.0, 18336.5, 18500.0, 18200.0, 18324.11, 7082.332356], [1607472000000.0, 18324.11, 18380.0, 18120.0, 18180.01, 4284.974779], [1607475600000.0, 18180.0, 18329.65, 18032.0, 18215.49, 4099.081784], [1607479200000.0, 18215.5, 18350.36, 18151.6, 18281.0, 2790.501477], [1607482800000.0, 18281.26, 18310.0, 18220.88, 18300.01, 1888.927345], [1607486400000.0, 18300.0, 18308.39, 18125.0, 18159.58, 2057.228798], [1607490000000.0, 18159.57, 18242.32, 18058.0, 18205.5, 2983.491849], [1607493600000.0, 18206.57, 18280.0, 18153.36, 18214.09, 2142.323158], [1607497200000.0, 18214.08, 18244.26, 17830.0, 17924.07, 5288.683222], [1607500800000.0, 17924.06, 18048.12, 17650.0, 17955.78, 8243.276243], [1607504400000.0, 17955.78, 18108.64, 17919.93, 18036.34, 4587.527362], [1607508000000.0, 18040.98, 18249.0, 17978.16, 18234.65, 4049.214998], [1607511600000.0, 18234.66, 18379.87, 18180.72, 18244.95, 4005.127067], [1607515200000.0, 18244.94, 18365.0, 18210.05, 18234.74, 2551.592987], [1607518800000.0, 18234.74, 18498.0, 18169.1, 18483.5, 3956.733743], [1607522400000.0, 18483.24, 18523.99, 18425.17, 18451.01, 3858.941662], [1607526000000.0, 18451.01, 18470.22, 18290.49, 18349.5, 3458.085019], [1607529600000.0, 18349.5, 18392.0, 18252.15, 18389.74, 2491.702939], [1607533200000.0, 18389.73, 18435.0, 18297.32, 18304.06, 2386.467822], [1607536800000.0, 18304.06, 18352.22, 18165.0, 18196.4, 2456.281166], [1607540400000.0, 18196.41, 18289.06, 18196.11, 18260.03, 1802.917152], [1607544000000.0, 18260.02, 18372.0, 18211.78, 18341.02, 2640.593524], [1607547600000.0, 18341.01, 18550.0, 18326.04, 18519.37, 3203.221946], [1607551200000.0, 18519.32, 18613.99, 18485.71, 18556.11, 2659.087548], [1607554800000.0, 18556.12, 18639.57, 18531.29, 18541.28, 1699.570211], [1607558400000.0, 18541.29, 18557.32, 18416.17, 18432.01, 1824.411371], [1607562000000.0, 18432.01, 18501.96, 18303.28, 18431.25, 1984.13813], [1607565600000.0, 18431.25, 18485.71, 18353.29, 18386.02, 1684.784578], [1607569200000.0, 18386.02, 18500.0, 18350.0, 18409.77, 1492.900435], [1607572800000.0, 18409.78, 18493.28, 18389.0, 18418.15, 1491.721766], [1607576400000.0, 18418.15, 18420.8, 18305.4, 18372.37, 1571.720153], [1607580000000.0, 18372.36, 18435.74, 18278.44, 18298.22, 2065.773007], [1607583600000.0, 18298.23, 18386.16, 18287.78, 18335.91, 1225.145125], [1607587200000.0, 18335.91, 18471.18, 18319.74, 18433.3, 1918.034657], [1607590800000.0, 18433.31, 18480.0, 18224.01, 18237.52, 2921.513279], [1607594400000.0, 18237.51, 18281.01, 18070.0, 18145.0, 3256.288961], [1607598000000.0, 18145.0, 18254.48, 18117.32, 18192.49, 2447.637312], [1607601600000.0, 18192.5, 18290.63, 18165.89, 18209.96, 1783.460883], [1607605200000.0, 18209.72, 18246.8, 18045.31, 18233.65, 3702.028832], [1607608800000.0, 18233.65, 18252.55, 18040.01, 18079.99, 3043.013527], [1607612400000.0, 18080.0, 18151.0, 17911.12, 18134.08, 4777.60176], [1607616000000.0, 18132.11, 18225.31, 18096.42, 18176.99, 2836.097029], [1607619600000.0, 18176.99, 18217.95, 18076.07, 18213.86, 1822.381199], [1607623200000.0, 18213.87, 18316.76, 18195.01, 18274.75, 2327.712761], [1607626800000.0, 18274.74, 18403.28, 18225.31, 18403.1, 2437.619642], [1607630400000.0, 18403.11, 18435.74, 18342.56, 18365.78, 2400.941605], [1607634000000.0, 18365.79, 18405.62, 18305.74, 18349.0, 1405.94011], [1607637600000.0, 18348.99, 18397.77, 18297.76, 18326.36, 1007.941783], [1607641200000.0, 18326.36, 18380.41, 18220.0, 18254.63, 1461.867189], [1607644800000.0, 18254.81, 18292.73, 17950.0, 18018.32, 3824.693799], [1607648400000.0, 18018.31, 18074.8, 17804.0, 17901.45, 5573.67197], [1607652000000.0, 17901.44, 18040.81, 17801.37, 17804.97, 3702.977958], [1607655600000.0, 17804.97, 17996.14, 17715.6, 17990.88, 3471.134571], [1607659200000.0, 17990.69, 18048.6, 17929.81, 17959.72, 2129.211271], [1607662800000.0, 17959.73, 18021.42, 17833.88, 17899.44, 2208.244252], [1607666400000.0, 17899.43, 17968.0, 17814.73, 17923.6, 1844.101172], [1607670000000.0, 17923.6, 17944.87, 17700.51, 17811.95, 3651.861489], [1607673600000.0, 17811.96, 18001.15, 17810.5, 17898.79, 3799.966897], [1607677200000.0, 17899.46, 17908.0, 17728.25, 17802.6, 2599.671677], [1607680800000.0, 17802.6, 17874.01, 17572.33, 17758.45, 5006.234384], [1607684400000.0, 17758.46, 17761.1, 17600.0, 17647.71, 3575.265625], [1607688000000.0, 17647.71, 17916.5, 17617.0, 17864.73, 4028.592436], [1607691600000.0, 17864.73, 17987.98, 17827.68, 17972.01, 2900.77125], [1607695200000.0, 17972.0, 18068.0, 17901.07, 18058.76, 3657.438567], [1607698800000.0, 18058.75, 18132.0, 18028.0, 18104.74, 3191.423603], [1607702400000.0, 18104.74, 18111.55, 17942.85, 17978.14, 2468.647851], [1607706000000.0, 17978.15, 18007.0, 17852.0, 17995.53, 2771.296577], [1607709600000.0, 17995.5, 18046.83, 17943.29, 18044.26, 1971.882831], [1607713200000.0, 18044.26, 18093.95, 17959.22, 17977.0, 2055.802959], [1607716800000.0, 17975.08, 18060.0, 17925.37, 17982.89, 2012.499396], [1607720400000.0, 17983.47, 18125.18, 17952.15, 18100.01, 2363.583895], [1607724000000.0, 18100.01, 18184.0, 18067.72, 18127.81, 1977.130093], [1607727600000.0, 18127.81, 18149.75, 18012.69, 18036.53, 1824.619736], [1607731200000.0, 18036.53, 18370.0, 18020.7, 18342.06, 4583.783306], [1607734800000.0, 18342.05, 18375.0, 18271.1, 18283.84, 1922.630745], [1607738400000.0, 18283.84, 18350.0, 18278.14, 18319.99, 1370.498539], [1607742000000.0, 18319.99, 18336.82, 18268.34, 18282.02, 1178.519836], [1607745600000.0, 18282.01, 18390.0, 18261.32, 18370.28, 1520.77657], [1607749200000.0, 18370.28, 18398.19, 18310.01, 18313.36, 1600.542043], [1607752800000.0, 18313.35, 18366.24, 18278.91, 18315.76, 1342.470282], [1607756400000.0, 18315.76, 18400.0, 18300.67, 18380.85, 1514.440818], [1607760000000.0, 18380.85, 18450.0, 18318.98, 18377.64, 2153.284652], [1607763600000.0, 18377.35, 18478.0, 18345.14, 18433.47, 1957.433154], [1607767200000.0, 18433.47, 18459.42, 18370.0, 18382.1, 1482.558626], [1607770800000.0, 18382.11, 18513.66, 18366.68, 18506.1, 1892.703046], [1607774400000.0, 18506.1, 18525.33, 18427.01, 18445.15, 1831.731773], [1607778000000.0, 18445.14, 18451.35, 18388.88, 18400.21, 1443.035834], [1607781600000.0, 18400.21, 18475.63, 18308.82, 18372.97, 2537.663833], [1607785200000.0, 18372.98, 18434.62, 18317.67, 18399.01, 1786.916682], [1607788800000.0, 18399.01, 18450.46, 18362.42, 18397.88, 1754.689938], [1607792400000.0, 18397.87, 18527.93, 18374.07, 18483.17, 1642.530837], [1607796000000.0, 18482.83, 18746.33, 18482.47, 18687.62, 4917.028007], [1607799600000.0, 18687.63, 18850.0, 18687.63, 18805.29, 3255.899793], [1607803200000.0, 18805.29, 18840.4, 18745.01, 18770.68, 2031.985016], [1607806800000.0, 18770.67, 18840.4, 18729.09, 18787.72, 1855.293184], [1607810400000.0, 18787.72, 18948.66, 18754.37, 18870.51, 2213.09286], [1607814000000.0, 18872.07, 18880.37, 18768.77, 18808.69, 1730.469058], [1607817600000.0, 18808.69, 18875.0, 18711.12, 18750.0, 2078.489661], [1607821200000.0, 18750.0, 18808.98, 18711.57, 18795.31, 1142.36527], [1607824800000.0, 18795.12, 18830.0, 18750.02, 18812.61, 1530.614138], [1607828400000.0, 18812.61, 18831.36, 18760.77, 18784.42, 932.07127], [1607832000000.0, 18784.56, 18884.5, 18768.19, 18852.51, 1252.673306], [1607835600000.0, 18852.51, 18938.55, 18815.0, 18850.0, 2197.616883], [1607839200000.0, 18849.99, 18984.55, 18844.95, 18973.93, 1930.019028], [1607842800000.0, 18973.93, 19306.27, 18973.93, 19247.68, 7062.527311], [1607846400000.0, 19249.21, 19322.24, 19179.5, 19245.91, 3622.141403], [1607850000000.0, 19245.25, 19301.14, 19185.74, 19263.25, 2399.866077], [1607853600000.0, 19263.26, 19370.0, 19255.0, 19278.99, 3202.08969], [1607857200000.0, 19278.99, 19349.0, 19200.0, 19316.81, 2247.859112], [1607860800000.0, 19316.8, 19400.0, 19265.91, 19348.97, 3516.700147], [1607864400000.0, 19348.97, 19411.0, 19290.01, 19321.13, 2820.186035], [1607868000000.0, 19321.13, 19364.0, 19275.41, 19292.13, 1662.41871], [1607871600000.0, 19292.13, 19334.08, 19080.0, 19211.34, 3876.890837], [1607875200000.0, 19211.34, 19385.0, 19208.7, 19328.12, 3401.557937], [1607878800000.0, 19328.11, 19334.85, 19197.36, 19273.6, 1835.01721], [1607882400000.0, 19273.59, 19273.6, 19172.33, 19185.33, 1636.424148], [1607886000000.0, 19185.33, 19233.0, 19155.0, 19193.78, 1307.778726], [1607889600000.0, 19193.28, 19193.29, 19089.63, 19154.57, 1908.19497], [1607893200000.0, 19154.56, 19225.63, 19150.0, 19190.01, 1153.642827], [1607896800000.0, 19190.0, 19194.33, 18971.0, 19112.41, 2215.95021], [1607900400000.0, 19112.41, 19225.0, 19090.0, 19174.99, 1627.726838], [1607904000000.0, 19174.99, 19174.99, 19000.0, 19057.19, 1888.751084], [1607907600000.0, 19056.23, 19112.74, 19019.65, 19085.64, 1246.655384], [1607911200000.0, 19085.64, 19307.09, 19052.62, 19297.7, 2765.118326], [1607914800000.0, 19297.9, 19347.0, 19227.01, 19260.68, 2413.537572], [1607918400000.0, 19260.68, 19282.11, 19055.51, 19126.94, 2675.257429], [1607922000000.0, 19126.93, 19130.0, 19033.84, 19085.53, 1556.626987], [1607925600000.0, 19085.53, 19207.93, 19080.4, 19206.23, 1741.878147], [1607929200000.0, 19206.24, 19210.0, 19100.43, 19155.8, 1326.684257], [1607932800000.0, 19155.8, 19244.69, 19140.0, 19222.0, 2065.456886], [1607936400000.0, 19222.0, 19258.96, 19120.0, 19184.61, 1986.246842], [1607940000000.0, 19184.6, 19215.19, 19052.72, 19088.94, 2101.350737], [1607943600000.0, 19088.94, 19144.14, 19051.11, 19106.44, 1864.319235], [1607947200000.0, 19106.43, 19147.0, 19028.97, 19114.66, 2118.835216], [1607950800000.0, 19114.66, 19199.86, 19063.31, 19184.19, 2220.236947], [1607954400000.0, 19184.2, 19227.66, 19088.88, 19221.74, 2439.250888], [1607958000000.0, 19221.75, 19300.0, 19158.57, 19200.07, 3589.350164], [1607961600000.0, 19200.0, 19237.79, 19114.58, 19152.37, 1896.167202], [1607965200000.0, 19152.59, 19170.51, 19113.08, 19143.09, 1255.884608], [1607968800000.0, 19143.38, 19236.76, 19143.38, 19236.75, 1817.186019], [1607972400000.0, 19236.75, 19236.76, 19165.0, 19175.88, 1343.180358], [1607976000000.0, 19175.88, 19216.57, 19130.85, 19208.07, 1386.346738], [1607979600000.0, 19208.08, 19220.85, 19150.85, 19197.83, 1359.138901], [1607983200000.0, 19197.82, 19296.0, 19188.76, 19291.9, 1317.369348], [1607986800000.0, 19291.99, 19349.0, 19200.85, 19273.14, 2882.372019], [1607990400000.0, 19273.69, 19395.0, 19243.64, 19394.94, 2998.531387], [1607994000000.0, 19394.94, 19470.0, 19320.85, 19455.06, 2941.282431], [1607997600000.0, 19455.06, 19570.0, 19444.25, 19458.97, 5055.6835], [1608001200000.0, 19458.98, 19509.57, 19416.35, 19479.7, 1934.600997], [1608004800000.0, 19479.69, 19497.36, 19161.03, 19180.71, 3179.726241], [1608008400000.0, 19180.7, 19259.78, 19050.0, 19132.4, 3984.043664], [1608012000000.0, 19133.29, 19211.76, 19101.0, 19186.12, 2257.168193], [1608015600000.0, 19186.12, 19226.95, 19146.95, 19197.6, 1730.803117], [1608019200000.0, 19197.59, 19219.96, 19106.95, 19137.24, 1854.09426], [1608022800000.0, 19136.96, 19209.99, 19074.0, 19187.51, 2498.768], [1608026400000.0, 19187.51, 19350.0, 19117.9, 19310.0, 3596.157208], [1608030000000.0, 19309.99, 19334.9, 19241.07, 19293.54, 1837.391665], [1608033600000.0, 19293.54, 19383.98, 19256.33, 19291.4, 2177.678422], [1608037200000.0, 19291.4, 19349.0, 19263.5, 19349.0, 1506.492391], [1608040800000.0, 19349.16, 19433.0, 19256.27, 19337.46, 3320.865118], [1608044400000.0, 19337.46, 19429.27, 19337.46, 19403.31, 2771.86623], [1608048000000.0, 19403.31, 19425.87, 19328.06, 19364.1, 2346.463058], [1608051600000.0, 19364.09, 19422.92, 19352.48, 19399.53, 1851.189273], [1608055200000.0, 19399.53, 19543.0, 19390.0, 19530.0, 3565.309308], [1608058800000.0, 19529.99, 19545.0, 19465.0, 19530.38, 2470.61552], [1608062400000.0, 19530.38, 19547.0, 19461.55, 19491.87, 1595.053706], [1608066000000.0, 19491.87, 19511.99, 19276.0, 19419.92, 3770.651415], [1608069600000.0, 19419.93, 19489.09, 19379.19, 19461.38, 1176.976643], [1608073200000.0, 19461.37, 19471.18, 19345.51, 19426.43, 1412.954264], [1608076800000.0, 19426.43, 19454.97, 19278.6, 19365.29, 2363.836441], [1608080400000.0, 19365.28, 19420.0, 19317.01, 19389.37, 1791.407177], [1608084000000.0, 19389.37, 19488.02, 19389.37, 19442.08, 1919.597241], [1608087600000.0, 19442.08, 19454.0, 19325.0, 19346.52, 2010.880432], [1608091200000.0, 19346.51, 19403.07, 19300.3, 19358.68, 1417.796057], [1608094800000.0, 19358.31, 19421.8, 19339.35, 19373.81, 1444.892753], [1608098400000.0, 19373.82, 19454.93, 19341.4, 19429.89, 1512.570737], [1608102000000.0, 19429.9, 19451.03, 19399.0, 19423.96, 1290.055946], [1608105600000.0, 19423.95, 19487.17, 19370.62, 19482.57, 1854.4269], [1608109200000.0, 19482.57, 19525.0, 19420.84, 19516.69, 2118.952672], [1608112800000.0, 19516.22, 19800.0, 19498.01, 19798.17, 8207.25114], [1608116400000.0, 19798.18, 19860.0, 19645.9, 19739.78, 5884.326771], [1608120000000.0, 19739.78, 19889.99, 19680.0, 19762.81, 6075.101618], [1608123600000.0, 19762.8, 20450.0, 19762.8, 20319.51, 11510.059772], [1608127200000.0, 20320.85, 20799.0, 20206.16, 20649.0, 14801.122043], [1608130800000.0, 20650.01, 20733.0, 20539.0, 20661.37, 7170.614379], [1608134400000.0, 20661.37, 20865.43, 20620.0, 20854.56, 7391.879642], [1608138000000.0, 20854.56, 20855.0, 20573.82, 20639.82, 5243.123474], [1608141600000.0, 20639.82, 20737.44, 20550.0, 20585.79, 3487.839577], [1608145200000.0, 20585.79, 20766.39, 20550.0, 20736.87, 2693.660582], [1608148800000.0, 20736.87, 20839.0, 20727.3, 20802.82, 3210.854218], [1608152400000.0, 20802.82, 21288.0, 20711.0, 21192.78, 7677.808354], [1608156000000.0, 21191.53, 21444.44, 21172.79, 21366.42, 6275.220393], [1608159600000.0, 21366.02, 21560.0, 21200.0, 21335.52, 6953.057251], [1608163200000.0, 21335.52, 21400.0, 21230.0, 21389.25, 4427.884694], [1608166800000.0, 21389.26, 21860.05, 21389.26, 21719.22, 6860.927779], [1608170400000.0, 21719.77, 21994.0, 21642.13, 21913.9, 5864.760533], [1608174000000.0, 21913.91, 22166.0, 21703.67, 21753.26, 7372.388063], [1608177600000.0, 21752.65, 21900.0, 21735.09, 21785.88, 3477.941858], [1608181200000.0, 21785.87, 22311.38, 21781.99, 22280.0, 5326.441496], [1608184800000.0, 22280.0, 22400.0, 22053.0, 22172.72, 6338.231327], [1608188400000.0, 22172.72, 22488.0, 22102.0, 22478.76, 5356.558094], [1608192000000.0, 22478.75, 22990.0, 22400.0, 22904.7, 12107.706886], [1608195600000.0, 22904.7, 23800.0, 21801.0, 22650.0, 23832.91859], [1608199200000.0, 22648.86, 22934.0, 22380.79, 22618.11, 11643.529767], [1608202800000.0, 22617.73, 22808.56, 22528.73, 22752.16, 5168.285261], [1608206400000.0, 22752.15, 23199.0, 22600.0, 23149.99, 6628.225882], [1608210000000.0, 23150.0, 23348.0, 22647.51, 22831.84, 7791.389716], [1608213600000.0, 22832.73, 23257.9, 22715.38, 23086.01, 7463.214961], [1608217200000.0, 23086.0, 23369.0, 22900.0, 23333.8, 6953.463504], [1608220800000.0, 23333.81, 23650.0, 23200.0, 23591.23, 10032.336464], [1608224400000.0, 23592.2, 23699.7, 23000.0, 23023.98, 6924.834078], [1608228000000.0, 23023.98, 23280.1, 22500.0, 23250.27, 11618.263635], [1608231600000.0, 23251.16, 23497.0, 22804.22, 22899.08, 7447.203472], [1608235200000.0, 22898.47, 23106.34, 22311.1, 22791.55, 8469.168153], [1608238800000.0, 22791.78, 22848.39, 22382.7, 22791.96, 5412.104163], [1608242400000.0, 22785.93, 23080.0, 22757.52, 22963.04, 3385.2089], [1608246000000.0, 22963.05, 23000.0, 22570.59, 22797.16, 4979.489472], [1608249600000.0, 22797.15, 22842.76, 22470.35, 22764.77, 3923.98594], [1608253200000.0, 22764.77, 23146.95, 22634.11, 22988.21, 4155.082395], [1608256800000.0, 22988.21, 23248.99, 22937.9, 23003.31, 3844.945472], [1608260400000.0, 23003.31, 23047.88, 22762.05, 22811.59, 3180.188409], [1608264000000.0, 22811.58, 22973.92, 22772.05, 22870.93, 2602.419348], [1608267600000.0, 22870.92, 23028.13, 22835.02, 22955.51, 2184.781421], [1608271200000.0, 22955.51, 23168.59, 22874.69, 22994.49, 3086.571333], [1608274800000.0, 22992.06, 23069.44, 22838.19, 23055.98, 3071.638245], [1608278400000.0, 23055.98, 23285.18, 22938.87, 23114.84, 4699.90906], [1608282000000.0, 23114.84, 23215.0, 23017.97, 23212.82, 2878.472119], [1608285600000.0, 23212.82, 23220.0, 22933.07, 22964.67, 3730.390769], [1608289200000.0, 22964.12, 22967.29, 22691.61, 22886.17, 4048.00235], [1608292800000.0, 22886.18, 23073.25, 22727.0, 22939.42, 3427.454017], [1608296400000.0, 22939.41, 22948.64, 22548.99, 22571.78, 4037.637495], [1608300000000.0, 22571.79, 22758.44, 22400.0, 22610.65, 5664.078883], [1608303600000.0, 22610.65, 22636.27, 22350.0, 22549.0, 4804.643291], [1608307200000.0, 22549.0, 22752.77, 22463.59, 22719.28, 3562.521096], [1608310800000.0, 22719.29, 22818.0, 22670.22, 22749.32, 2476.345002], [1608314400000.0, 22749.32, 22829.04, 22694.22, 22781.44, 2092.86434], [1608318000000.0, 22781.44, 22783.42, 22626.81, 22762.28, 1880.94478], [1608321600000.0, 22762.28, 22795.11, 22650.29, 22755.66, 1760.425011], [1608325200000.0, 22755.25, 23078.47, 22751.1, 22880.07, 3998.59848], [1608328800000.0, 22880.07, 23034.33, 22800.0, 23011.38, 1992.223703], [1608332400000.0, 23011.38, 23143.56, 22908.45, 23107.39, 2542.011356], [1608336000000.0, 23107.39, 23168.28, 22940.0, 22954.02, 2050.965871], [1608339600000.0, 22954.02, 23099.0, 22902.1, 23046.76, 2149.001638], [1608343200000.0, 23046.75, 23220.0, 23034.75, 23220.0, 2910.223586], [1608346800000.0, 23219.51, 23225.0, 23093.93, 23098.04, 2329.278504], [1608350400000.0, 23098.05, 23138.0, 23032.0, 23043.4, 1859.427393], [1608354000000.0, 23043.41, 23075.36, 22924.79, 22958.0, 2023.000091], [1608357600000.0, 22958.48, 23010.6, 22821.0, 22853.5, 1813.015202], [1608361200000.0, 22853.51, 23038.0, 22832.0, 22853.75, 1791.212957], [1608364800000.0, 22853.75, 22990.0, 22750.0, 22983.77, 3048.665524], [1608368400000.0, 22983.77, 23045.49, 22928.05, 22973.06, 2229.925469], [1608372000000.0, 22973.06, 23080.45, 22950.0, 23019.99, 2331.997056], [1608375600000.0, 23020.0, 23063.49, 22875.01, 22888.54, 1942.204736], [1608379200000.0, 22888.53, 23127.72, 22886.79, 23041.53, 3218.568336], [1608382800000.0, 23041.53, 23189.1, 22992.23, 23172.74, 3234.586672], [1608386400000.0, 23172.75, 23627.99, 23052.0, 23296.96, 10267.116714], [1608390000000.0, 23296.96, 23650.0, 23296.95, 23552.01, 6178.384265], [1608393600000.0, 23552.0, 24171.47, 23456.55, 23966.48, 13881.907694], [1608397200000.0, 23966.48, 24100.0, 23680.0, 23886.44, 6294.413079], [1608400800000.0, 23886.71, 23906.66, 23556.76, 23822.66, 4060.081676], [1608404400000.0, 23822.66, 23883.79, 23651.0, 23791.91, 3011.088133], [1608408000000.0, 23791.82, 23937.0, 23782.91, 23902.17, 2634.921207], [1608411600000.0, 23902.19, 24065.41, 23780.21, 23974.71, 3253.897865], [1608415200000.0, 23974.7, 23999.0, 23825.95, 23905.73, 1543.403326], [1608418800000.0, 23905.73, 23915.26, 23719.25, 23821.61, 1987.777683], [1608422400000.0, 23821.6, 23836.48, 23230.0, 23481.41, 5981.312918], [1608426000000.0, 23483.11, 23548.72, 23390.0, 23485.56, 2118.503927], [1608429600000.0, 23486.42, 23542.99, 23300.0, 23429.92, 2007.609104], [1608433200000.0, 23429.92, 23429.92, 23180.88, 23346.48, 2578.315434], [1608436800000.0, 23346.25, 23452.63, 23060.0, 23426.54, 2789.303747], [1608440400000.0, 23426.15, 23588.88, 23397.58, 23481.38, 2113.937874], [1608444000000.0, 23481.38, 23614.84, 23459.98, 23506.67, 1518.982809], [1608447600000.0, 23506.67, 23646.31, 23410.62, 23628.88, 2081.734038], [1608451200000.0, 23628.89, 23791.0, 23532.0, 23698.49, 2953.113346], [1608454800000.0, 23698.49, 23748.4, 23503.0, 23592.92, 2928.141217], [1608458400000.0, 23592.93, 23648.92, 23358.78, 23394.76, 3238.112754], [1608462000000.0, 23394.77, 23625.0, 23393.0, 23553.02, 1945.517865], [1608465600000.0, 23553.02, 23588.71, 23333.57, 23472.44, 2466.127241], [1608469200000.0, 23472.45, 23590.9, 23296.0, 23561.36, 3044.961002], [1608472800000.0, 23561.36, 23682.0, 23500.19, 23537.7, 2538.180355], [1608476400000.0, 23537.7, 23910.0, 23527.48, 23868.09, 4012.70827], [1608480000000.0, 23868.08, 23901.01, 23628.31, 23630.1, 3492.542838], [1608483600000.0, 23630.1, 23800.0, 23625.41, 23722.62, 2050.917554], [1608487200000.0, 23722.62, 23877.0, 23655.26, 23866.69, 2433.334581], [1608490800000.0, 23866.68, 23995.97, 23780.83, 23930.0, 2727.20596], [1608494400000.0, 23928.8, 24295.0, 23850.18, 24172.25, 6396.944648], [1608498000000.0, 24172.99, 24208.62, 23350.0, 23373.05, 7510.998452], [1608501600000.0, 23376.94, 23614.36, 23090.0, 23507.79, 5563.847281], [1608505200000.0, 23507.03, 23594.73, 23450.0, 23455.52, 2197.79247], [1608508800000.0, 23455.54, 23709.31, 23287.94, 23679.55, 3148.360382], [1608512400000.0, 23679.55, 23744.86, 23587.85, 23663.48, 1823.646184], [1608516000000.0, 23663.49, 23887.0, 23652.48, 23856.38, 2136.222374], [1608519600000.0, 23856.39, 24016.93, 23657.5, 23945.29, 3842.134228], [1608523200000.0, 23945.3, 24102.77, 23790.0, 23895.73, 3537.794056], [1608526800000.0, 23895.02, 23975.0, 23841.99, 23909.83, 2315.212127], [1608530400000.0, 23909.83, 23926.8, 23700.0, 23921.73, 2593.611244], [1608534000000.0, 23921.74, 24028.15, 23888.15, 23980.0, 3110.573389], [1608537600000.0, 23979.99, 24075.94, 23635.08, 23659.59, 4670.537852], [1608541200000.0, 23659.59, 23739.18, 23328.0, 23461.35, 6435.521197], [1608544800000.0, 23460.93, 23495.91, 22441.01, 22445.99, 13511.910357], [1608548400000.0, 22446.39, 22833.01, 22350.0, 22645.85, 7582.69498], [1608552000000.0, 22646.7, 22663.0, 21815.0, 22307.5, 11239.201922], [1608555600000.0, 22307.5, 22665.35, 22251.23, 22646.53, 2830.345587], [1608559200000.0, 22646.53, 22646.53, 22646.53, 22646.53, 0.0], [1608573600000.0, 22693.65, 22988.6, 22621.44, 22813.66, 4796.306546], [1608577200000.0, 22813.65, 22940.3, 22681.32, 22816.62, 2874.578236], [1608580800000.0, 22816.63, 22930.0, 22732.78, 22829.79, 1900.463944], [1608584400000.0, 22829.79, 23162.26, 22765.0, 23127.37, 3028.124973], [1608588000000.0, 23127.38, 23254.33, 23021.17, 23170.89, 2084.951696], [1608591600000.0, 23169.88, 23228.35, 22699.99, 22719.71, 3712.997151], [1608595200000.0, 22719.88, 22926.14, 22500.0, 22558.42, 4435.40638], [1608598800000.0, 22558.41, 22875.61, 22428.86, 22753.99, 3850.003384], [1608602400000.0, 22753.99, 22970.0, 22730.0, 22957.57, 2524.453641], [1608606000000.0, 22957.58, 22969.0, 22737.9, 22851.2, 2438.400079], [1608609600000.0, 22851.19, 23076.77, 22851.19, 22951.3, 2681.871362], [1608613200000.0, 22951.3, 22970.0, 22610.19, 22681.51, 3600.425979], [1608616800000.0, 22681.5, 22870.02, 22551.02, 22750.96, 3044.631907], [1608620400000.0, 22750.85, 22826.78, 22500.0, 22655.83, 3555.118357], [1608624000000.0, 22655.82, 22750.0, 22353.4, 22689.86, 4786.238036], [1608627600000.0, 22689.87, 22822.83, 22600.0, 22783.85, 3441.122021], [1608631200000.0, 22782.92, 22819.76, 22602.12, 22701.2, 2835.326798], [1608634800000.0, 22701.2, 23155.0, 22701.2, 23119.48, 4983.540952], [1608638400000.0, 23119.47, 23300.0, 23062.01, 23175.57, 4777.70466], [1608642000000.0, 23175.57, 23536.96, 23103.98, 23487.2, 4972.84068], [1608645600000.0, 23487.2, 23628.89, 23335.83, 23439.99, 6009.583132], [1608649200000.0, 23439.99, 23600.0, 23300.42, 23342.58, 4452.75789], [1608652800000.0, 23342.68, 23456.0, 23237.0, 23348.95, 4298.902555], [1608656400000.0, 23348.43, 23442.0, 23224.4, 23342.54, 2756.859302], [1608660000000.0, 23342.54, 23520.0, 23342.51, 23435.27, 2784.592049]] } @pytest.fixture def default_trades_data(): # imported from real backtesting data return { "BTC/USDT": [ {commons_enums.PlotAttributes.X.value: 1607986800000, commons_enums.PlotAttributes.VOLUME.value: 0.00617086, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 19291.9, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.BUY.value, commons_enums.DBRows.FEES_AMOUNT.value: 6.17e-06, commons_enums.DBRows.FEES_CURRENCY.value: 'BTC'}, {commons_enums.PlotAttributes.X.value: 1608040800000, commons_enums.PlotAttributes.VOLUME.value: 0.00614347, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 19349.0, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.BUY.value, commons_enums.DBRows.FEES_AMOUNT.value: 6.14e-06, commons_enums.DBRows.FEES_CURRENCY.value: 'BTC'}, {commons_enums.PlotAttributes.X.value: 1608134400000, commons_enums.PlotAttributes.VOLUME.value: 0.00616469, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 20835.252, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.SELL.value, commons_enums.DBRows.FEES_AMOUNT.value: 0.12834515, commons_enums.DBRows.FEES_CURRENCY.value: 'USDT'}, {commons_enums.PlotAttributes.X.value: 1608152400000, commons_enums.PlotAttributes.VOLUME.value: 0.00613733, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 20896.92, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.SELL.value, commons_enums.DBRows.FEES_AMOUNT.value: 0.12830709, commons_enums.DBRows.FEES_CURRENCY.value: 'USDT'}, {commons_enums.PlotAttributes.X.value: 1608343200000, commons_enums.PlotAttributes.VOLUME.value: 0.00526114, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 23046.76, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.BUY.value, commons_enums.DBRows.FEES_AMOUNT.value: 5.26e-06, commons_enums.DBRows.FEES_CURRENCY.value: 'BTC'}, {commons_enums.PlotAttributes.X.value: 1608390000000, commons_enums.PlotAttributes.VOLUME.value: 0.0051656, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 23296.96, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.BUY.value, commons_enums.DBRows.FEES_AMOUNT.value: 5.17e-06, commons_enums.DBRows.FEES_CURRENCY.value: 'BTC'}, {commons_enums.PlotAttributes.X.value: 1608548400000, commons_enums.PlotAttributes.VOLUME.value: 0.00516043, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 22365.0816, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.SELL.value, commons_enums.DBRows.FEES_AMOUNT.value: 0.11540382, commons_enums.DBRows.FEES_CURRENCY.value: 'USDT'}, {commons_enums.PlotAttributes.X.value: 1608552000000, commons_enums.PlotAttributes.VOLUME.value: 0.00525588, commons_enums.DBRows.SYMBOL.value: "BTC/USDT", commons_enums.PlotAttributes.Y.value: 22124.8896, commons_enums.PlotAttributes.SIDE.value: trading_enums.TradeOrderSide.SELL.value, commons_enums.DBRows.FEES_AMOUNT.value: 0.11637692, commons_enums.DBRows.FEES_CURRENCY.value: 'USDT'}, ] } @pytest.fixture def default_portfolio_historical_value(): # imported from real backtesting data, verified values return [1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 999.8815237991, 999.7687099720999, 1000.5161786346, 1000.8867997973999, 1000.9109653822, 1001.0386361121, 999.195455449, 998.9031874960999, 999.2288680688, 999.2995770631, 998.9258119084, 999.2374369879, 999.9924882191, 999.8910790686, 999.877886632, 1000.1161382391999, 999.9722046052, 1000.7822926222, 1000.2998073977999, 1000.7357909865999, 1002.3407125158, 1002.3455103035999, 1001.8717595133999, 1000.9867521946, 1001.4965479033999, 1001.0667153246, 1000.3144468016, 1000.6108024634, 1001.2592419376, 1000.0835378861999, 1000.2287017222, 1000.4195060523999, 1001.1094033339999, 1001.036206315, 1001.7573507274, 1002.1713137003999, 1005.6399912595999, 1004.9215532915999, 1005.2047457919999, 1012.0698880529999, 1016.1192209561999, 1017.2025553799799, 1018.38822616268, 1017.0702959184799, 1016.7386959785799, 1017.6659237949799, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.5198963714798, 1018.3986178550799, 1019.3066236838799, 1018.6682444990798, 1018.3810632158799, 1017.9346813274799, 1017.3829716038799, 1017.3842330150799, 1018.0676025326799, 1018.0113120578799, 1018.2580230650799, 1017.5670325214799, 1018.3711821614799, 1019.0608587350798, 1019.5932463066798, 1022.2498220090798, 1026.56717417788, 1025.7362651291799, 1025.0691004736798, 1024.74786147328, 1025.8975096079798, 1026.6527962460798, 1025.93438334538, 1025.0580591850799, 1021.5322424131798, 1021.5667203992798, 1020.9781988842799, 1020.1066662265798, 1020.9389293955799, 1021.5142221968799, 1021.7776506767798, 1023.0507320849798, 1023.7757072609799, 1022.6761615773798, 1020.6120655877799, 1022.2604466452799, 1021.4212045485799, 1022.3473186706799, 1022.1008687760799, 1025.54220927388, 1023.0633358200798, 1024.0270528212798, 1025.52762643988, 1026.1746876170798, 1028.7182463559798, 1020.4263427804799, 1021.7814005483799, 1021.2450647464799, 1023.5784223495798, 1023.4111364109799, 1025.4204426099798, 1026.3465567320798, 1025.8228246652798, 1025.9770902163798, 1026.1011484684798, 1026.7078985259798, 1023.3705128019799, 1021.3012086573799, 1010.1984553833678, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158, 1008.3923109410158] @pytest.fixture def default_portfolio_data(): return {'BTC': 0.0, 'USDT': 1000.0} @pytest.fixture def default_spot_metadata(): return { commons_enums.DBRows.EXCHANGES.value: "binance", commons_enums.DBRows.FUTURE_CONTRACTS.value: {}, } @pytest.fixture def default_pnl_historical_value(): # imported from real backtesting data, verified values # add 0 at the end for the end backtesting value return [0, 9.266910467879995, 9.25298590360002, -3.7521819056716623, -6.375403524792318, 0] @pytest.fixture def default_funding_fees_data(): #TODO return [] @pytest.fixture def default_realized_pnl_history(): #TODO return [] ================================================ FILE: Meta/Keywords/scripting_library/tests/backtesting/test_backtesting_data_collector.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.enums as common_enums import octobot_commons.constants as common_constants import octobot.constants as constants import tentacles.Meta.Keywords.scripting_library.backtesting.backtesting_data_collector as src_backtesting_data_collector import tentacles.Meta.Keywords.scripting_library.errors as errors class DummyLogger: def __init__(self): self.infos = [] self.errors = [] self.exceptions = [] def info(self, msg): self.infos.append(msg) def error(self, msg): self.errors.append(msg) def exception(self, err, *args, **kwargs): self.exceptions.append((err, args, kwargs)) def patch_logger(monkeypatch): logger = DummyLogger() monkeypatch.setattr(src_backtesting_data_collector, "_get_logger", lambda: logger) return logger def base_args(): return dict( exchange="binance", symbol="BTC/USDT", time_frame=common_enums.TimeFrames.ONE_HOUR, allow_candles_beyond_range=False, required_from_the_start=True, required_till_the_end=True, first_traded_symbols_time=9999999999, # large for test allow_any_backtesting_start_and_end_time=False, ) def test_ensure_compatible_candle_time_normal_case(monkeypatch): logger = patch_logger(monkeypatch) args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 10 * tf_sec first_candle_time = first_open_time last_candle_time = last_open_time result = src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert result is None assert not logger.errors assert not logger.infos def test_ensure_compatible_candle_time_starts_too_early(): args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 10 * tf_sec first_candle_time = first_open_time - constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW - 1 last_candle_time = last_open_time with pytest.raises(errors.InvalidBacktestingDataError) as exc: src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert "starts too early" in str(exc.value) def test_ensure_compatible_candle_time_starts_too_late_and_required(): args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 10 * tf_sec first_candle_time = first_open_time + tf_sec * 2 last_candle_time = last_open_time args["first_traded_symbols_time"] = first_open_time - constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW # force fail with pytest.raises(errors.InvalidBacktestingDataError) as exc: src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert "starts too late" in str(exc.value) def test_ensure_compatible_candle_time_starts_too_late_but_adapted_with_test_data(monkeypatch): logger = patch_logger(monkeypatch) args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 10 * tf_sec first_candle_time = first_open_time + tf_sec * 2 last_candle_time = last_open_time args["first_traded_symbols_time"] = first_open_time + tf_sec * 3 # allow adaptation result = src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert result == first_candle_time assert any("acceptable, start time is adapted" in msg for msg in logger.infos) def test_ensure_compatible_candle_time_starts_too_late_but_adapted_with_real_data_dca(monkeypatch): logger = patch_logger(monkeypatch) args = base_args() args["time_frame"] = common_enums.TimeFrames.FOUR_HOURS first_open_time = 1737424774.2265518 # Tuesday, January 21, 2025 9:44:54.459 last_open_time = 1752990294.4590356 # Sunday, July 20, 2025 5:44:54.459 first_candle_time = 1737446400 # Tuesday, January 21, 2025 12:00:00 last_candle_time = 1752955200 # Saturday, July 19, 2025 20:00:00 args["first_traded_symbols_time"] = 1737465882.5380511 # Tuesday, January 21, 2025 13:24:42.538 # fails without the kw_constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW allowance over first_traded_symbols_time result = src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert result == first_candle_time assert any("acceptable, start time is adapted" in msg for msg in logger.infos) first_candle_time = args["first_traded_symbols_time"] + constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW # Thursday, January 23, 2025 13:24:42.538 with pytest.raises(errors.InvalidBacktestingDataError) as exc: result = src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert "starts too late" in str(exc.value) def test_ensure_compatible_candle_time_starts_too_late_but_adapted_with_real_data_basked(monkeypatch): logger = patch_logger(monkeypatch) args = base_args() args["time_frame"] = common_enums.TimeFrames.FOUR_HOURS first_open_time = 1737453626.6562696 # Tuesday, January 21, 2025 10:00:26.656 last_open_time = 1752919226.658268 # Saturday, July 19, 2025 10:00:26.658 first_candle_time = 1737590400 # Thursday, January 23, 2025 0:00:00 last_candle_time = 1752883200 # Saturday, July 19, 2025 0:00:00 args["first_traded_symbols_time"] = 1749325565.048149 # Saturday, June 7, 2025 19:46:05.048 result = src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert result == first_candle_time assert any("acceptable, start time is adapted" in msg for msg in logger.infos) def test_ensure_compatible_candle_time_ends_too_late(): args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 10 * tf_sec first_candle_time = first_open_time last_candle_time = last_open_time + tf_sec * 2 with pytest.raises(errors.InvalidBacktestingDataError) as exc: src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert "ends too late" in str(exc.value) def test_ensure_compatible_candle_time_ends_too_early_and_required(): args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 10 * tf_sec first_candle_time = first_open_time last_candle_time = last_open_time - constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW - 1 with pytest.raises(errors.InvalidBacktestingDataError) as exc: src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert "ends too early" in str(exc.value) def test_ensure_compatible_candle_time_ends_too_early_but_not_required(monkeypatch): logger = patch_logger(monkeypatch) args = base_args() args["required_till_the_end"] = False first_open_time = 1000000 last_open_time = 1000000 + constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW first_candle_time = first_open_time last_candle_time = last_open_time - constants.BACKTESTING_DATA_ALLOWED_PRICE_WINDOW - 1 result = src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert result is None assert any("acceptable, this symbol is not required till the end" in msg for msg in logger.infos) def test_ensure_compatible_candle_time_adapted_start_time_too_short(): args = base_args() tf_sec = common_enums.TimeFramesMinutes[args["time_frame"]] * common_constants.MINUTE_TO_SECONDS first_open_time = 1000000 last_open_time = 1000000 + 30 * tf_sec first_candle_time = first_open_time + 25 * tf_sec last_candle_time = last_open_time args["first_traded_symbols_time"] = first_open_time + 30 * tf_sec # This will adapt, but duration will be too short with pytest.raises(errors.InvalidBacktestingDataError) as exc: src_backtesting_data_collector.ensure_compatible_candle_time( **args, first_open_time=first_open_time, last_open_time=last_open_time, first_candle_time=first_candle_time, last_candle_time=last_candle_time, ) assert "adapted backtesting start time starts too late" in str(exc.value) ================================================ FILE: Meta/Keywords/scripting_library/tests/backtesting/test_collect_data_and_run_backtesting.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.enums as commons_enums import octobot_commons.profiles.profile_data as commons_profile_data import octobot_commons.constants as commons_constants import octobot_trading.api import octobot_trading.exchanges.connectors.ccxt.ccxt_clients_cache as ccxt_clients_cache import octobot_trading.util.test_tools.exchange_data as exchange_data_import import tentacles.Meta.Keywords.scripting_library as scripting_library import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading @pytest.fixture def trading_mode_tentacles_data() -> commons_profile_data.TentaclesData: distribution = [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 50.0, }, { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 30.0, }, { index_distribution.DISTRIBUTION_NAME: "USD", # Will be replaced by reference market index_distribution.DISTRIBUTION_VALUE: 20.0, }, ] # Create test trading mode config trading_mode_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: distribution, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 5.0, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "test_profile", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "test_profile", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.0, } ], } return commons_profile_data.TentaclesData( name=index_trading.IndexTradingMode.get_name(), config=trading_mode_config ) @pytest.mark.asyncio async def test_collect_candles_without_backend_and_run_backtesting(trading_mode_tentacles_data): # 1. init strategy exchange_data = exchange_data_import.ExchangeData() # run backtesting for 200 days days = 200 profile_data = scripting_library.create_index_config_from_tentacles_config( tentacles_config=[trading_mode_tentacles_data], exchange="binanceus", starting_funds=1000, backtesting_start_time_delta=days * commons_constants.DAYS_TO_SECONDS ) # 2. collect candles ccxt_clients_cache._MARKETS_BY_EXCHANGE.clear() await scripting_library.init_exchange_market_status_and_populate_backtesting_exchange_data( exchange_data, profile_data ) # cached markets have been updated and now contain this exchange markets assert len(ccxt_clients_cache._MARKETS_BY_EXCHANGE) == 1 # ensure collected datas are correct assert len(exchange_data.markets) == 2 assert sorted([market.symbol for market in exchange_data.markets]) == ["BTC/USDT", "ETH/USDT"] for market in exchange_data.markets: assert market.time_frame == commons_enums.TimeFrames.ONE_DAY.value assert days - 1 <= len(market.close) <= days assert days - 1 <= len(market.open) <= days assert days - 1 <= len(market.high) <= days assert days - 1 <= len(market.low) <= days assert days - 1 <= len(market.volume) <= days assert days - 1 <= len(market.time) <= days starting_portfolio = profile_data.backtesting_context.starting_portfolio assert starting_portfolio == { "USDT": 1000, } # 3. run backtesting async with scripting_library.init_and_run_backtesting( exchange_data, profile_data ) as independent_backtesting: # backtesting completed, make sure it executed correctly for exchange_id in independent_backtesting.octobot_backtesting.exchange_manager_ids: exchange_manager = octobot_trading.api.get_exchange_manager_from_exchange_id(exchange_id) ending_portfolio = octobot_trading.api.get_portfolio(exchange_manager, as_decimal=False) assert ending_portfolio != starting_portfolio assert "ETH" in ending_portfolio assert "BTC" in ending_portfolio assert "USDT" in ending_portfolio trades = octobot_trading.api.get_trade_history(exchange_manager) # at least 2 trades are expected, one for each symbol assert len(trades) >= 2 # backtesting is not stopped yet assert independent_backtesting.stopped is False # 4. ensure backtesting is stopped assert independent_backtesting.stopped is True ================================================ FILE: Meta/Keywords/scripting_library/tests/backtesting/test_run_data.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.backtesting.run_data_analysis as run_data_analysis import octobot_trading.enums as trading_enums import octobot_commons.enums as commons_enums from tentacles.Meta.Keywords.scripting_library.tests import event_loop from tentacles.Meta.Keywords.scripting_library.tests.backtesting.data_store import default_price_data, \ default_trades_data, default_portfolio_data, default_portfolio_historical_value, default_pnl_historical_value, \ default_funding_fees_data, default_realized_pnl_history, default_spot_metadata # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_plot_historical_portfolio_value(default_price_data, default_trades_data, default_portfolio_data, default_portfolio_historical_value, default_funding_fees_data, default_spot_metadata): expected_time_data = [candle[commons_enums.PriceIndexes.IND_PRICE_TIME.value] for candle in default_price_data["BTC/USDT"]] await _test_historical_portfolio_values(default_price_data, default_trades_data, default_portfolio_data, default_funding_fees_data, expected_time_data, default_portfolio_historical_value, "spot", default_spot_metadata) async def test_get_historical_pnl(default_price_data, default_trades_data, default_pnl_historical_value, default_realized_pnl_history, default_spot_metadata): # expected_time_data start at the 1st time data with a default_pnl_historical_value at 0 expected_time_data = \ [default_price_data["BTC/USDT"][0][commons_enums.PriceIndexes.IND_PRICE_TIME.value]] + \ [trade[commons_enums.PlotAttributes.X.value] for trade in default_trades_data["BTC/USDT"] if trade[commons_enums.PlotAttributes.SIDE.value] == trading_enums.TradeOrderSide.SELL.value] + \ [default_price_data["BTC/USDT"][-1][commons_enums.PriceIndexes.IND_PRICE_TIME.value]] cumulative_pnl_historical_value = [default_pnl_historical_value[0]] for value in default_pnl_historical_value[1:]: cumulative_pnl_historical_value.append(cumulative_pnl_historical_value[-1] + value) await _test_historical_pnl_values_from_trades(default_price_data, default_trades_data, [], False, True, False, expected_time_data, default_pnl_historical_value, cumulative_pnl_historical_value, "spot", default_spot_metadata) expected_time_data = [i for i in range(len(cumulative_pnl_historical_value))] await _test_historical_pnl_values_from_trades(default_price_data, default_trades_data, default_realized_pnl_history, True, True, True, expected_time_data, default_pnl_historical_value, cumulative_pnl_historical_value, "spot", default_spot_metadata) await _test_historical_pnl_values_from_trades(default_price_data, default_trades_data, default_realized_pnl_history, False, False, True, expected_time_data, default_pnl_historical_value, cumulative_pnl_historical_value, "spot", default_spot_metadata) async def test_total_paid_fees(default_trades_data): usdt_fees = sum(trade[commons_enums.DBRows.FEES_AMOUNT.value] for trade in default_trades_data["BTC/USDT"] if trade[commons_enums.DBRows.FEES_CURRENCY.value] == "USDT") btc_fees_in_usdt = sum(trade[commons_enums.DBRows.FEES_AMOUNT.value] * trade[commons_enums.PlotAttributes.Y.value] for trade in default_trades_data["BTC/USDT"] if trade[commons_enums.DBRows.FEES_CURRENCY.value] == "BTC") with mock.patch.object(run_data_analysis, "get_transactions", mock.AsyncMock(return_value=[])) as get_transactions_mock: assert round(await run_data_analysis.total_paid_fees(None, default_trades_data["BTC/USDT"]), 15) == \ round(usdt_fees + btc_fees_in_usdt, 15) get_transactions_mock.assert_called_once() async def _test_historical_portfolio_values(price_data, trades_data, portfolio_data, funding_fees_data, expected_time_data, expected_value_data, exchange_type, spot_metadata): plotted_element = mock.Mock() with mock.patch.object(run_data_analysis, "load_historical_values", mock.AsyncMock(return_value=(price_data, trades_data, portfolio_data, exchange_type, spot_metadata, spot_metadata))) \ as load_historical_values_mock, \ mock.patch.object(run_data_analysis, "get_transactions", mock.AsyncMock(return_value=funding_fees_data)) \ as get_transactions_mock: await run_data_analysis.plot_historical_portfolio_value("meta_database", plotted_element, exchange="exchange", own_yaxis=True) load_historical_values_mock.assert_called_once_with("meta_database", "exchange") get_transactions_mock.assert_called_once_with("meta_database", transaction_type=trading_enums.TransactionType.FUNDING_FEE.value) plotted_element.plot.assert_called_once_with( mode="scatter", x=expected_time_data, y=expected_value_data, title="Portfolio value", own_yaxis=True ) async def _test_historical_pnl_values_from_trades(price_data, trades_data, pnl_data, include_cumulative, include_unitary, x_as_trade_count, expected_time_data, expected_value_data, expected_cumulative_values, exchange_type, spot_metadata): plotted_element = mock.Mock() with mock.patch.object(run_data_analysis, "load_historical_values", mock.AsyncMock(return_value=(price_data, trades_data, None, exchange_type, spot_metadata, spot_metadata))) \ as load_historical_values_mock, \ mock.patch.object(run_data_analysis, "get_transactions", mock.AsyncMock(return_value=pnl_data)) \ as get_transactions_mock: await run_data_analysis._get_historical_pnl("meta_database", plotted_element, include_cumulative, include_unitary, exchange="exchange", x_as_trade_count=x_as_trade_count, own_yaxis=True) load_historical_values_mock.assert_called_once_with("meta_database", "exchange") get_transactions_mock.assert_called_once_with("meta_database", transaction_types=( trading_enums.TransactionType.TRADING_FEE.value, trading_enums.TransactionType.FUNDING_FEE.value, trading_enums.TransactionType.REALISED_PNL.value, trading_enums.TransactionType.CLOSE_REALISED_PNL.value) ) if include_cumulative: assert plotted_element.plot.call_count == 2 else: if include_unitary: plotted_element.plot.assert_called_once_with( kind="bar", x=expected_time_data, y=expected_value_data, x_type="tick0" if x_as_trade_count else "date", title="P&L per trade", own_yaxis=True ) else: plotted_element.assert_not_called() ================================================ FILE: Meta/Keywords/scripting_library/tests/configuration/__init__.py ================================================ import os import pathlib import pytest import pytest_asyncio import octobot_commons.constants as commons_constants import octobot_backtesting.backtesting as backtesting import octobot_backtesting.constants as backtesting_constants import octobot_backtesting.time as backtesting_time import octobot_trading.exchanges as exchanges from octobot_commons.tests.test_config import load_test_config pytestmark = pytest.mark.asyncio DEFAULT_EXCHANGE_NAME = "binance" TEST_CONFIG_FOLDER = pathlib.Path(os.path.abspath(__file__)).parent.parent @pytest_asyncio.fixture async def backtesting_config(request): config = load_test_config(test_folder=TEST_CONFIG_FOLDER) config[backtesting_constants.CONFIG_BACKTESTING] = {} config[backtesting_constants.CONFIG_BACKTESTING][commons_constants.CONFIG_ENABLED_OPTION] = True if hasattr(request, "param"): ref_market = request.param config[commons_constants.CONFIG_TRADING][commons_constants.CONFIG_TRADER_REFERENCE_MARKET] = ref_market return config @pytest_asyncio.fixture async def fake_backtesting(backtesting_config): return backtesting.Backtesting( config=backtesting_config, exchange_ids=[], matrix_id="", backtesting_files=[], ) @pytest_asyncio.fixture async def backtesting_exchange_manager(request, backtesting_config, fake_backtesting): config = None exchange_name = DEFAULT_EXCHANGE_NAME is_spot = True is_margin = False is_future = False if hasattr(request, "param"): config, exchange_name, is_spot, is_margin, is_future = request.param if config is None: config = backtesting_config exchange_manager_instance = exchanges.ExchangeManager(config, exchange_name) exchange_manager_instance.is_backtesting = True exchange_manager_instance.is_spot_only = is_spot exchange_manager_instance.is_margin = is_margin exchange_manager_instance.is_future = is_future exchange_manager_instance.use_cached_markets = False exchange_manager_instance.backtesting = fake_backtesting exchange_manager_instance.backtesting.time_manager = backtesting_time.TimeManager(config) await exchange_manager_instance.initialize(exchange_config_by_exchange=None) yield exchange_manager_instance await exchange_manager_instance.stop() @pytest_asyncio.fixture async def backtesting_trader(backtesting_config, backtesting_exchange_manager): trader_instance = exchanges.TraderSimulator(backtesting_config, backtesting_exchange_manager) await trader_instance.initialize() return backtesting_config, backtesting_exchange_manager, trader_instance ================================================ FILE: Meta/Keywords/scripting_library/tests/configuration/test_indexes_configuration.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.profiles.profile_data as commons_profile_data import octobot_commons.constants as commons_constants import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tentacles.Meta.Keywords.scripting_library.configuration.indexes_configuration as indexes_configuration def test_create_index_config_from_tentacles_config(): # Create test distribution distribution = [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 50.0, }, { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 30.0, }, { index_distribution.DISTRIBUTION_NAME: "USD", # Should be replaced by reference market index_distribution.DISTRIBUTION_VALUE: 20.0, }, ] # Create test trading mode config trading_mode_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: distribution, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 5.0, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "test_profile", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "test_profile", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.0, } ], } # Create tentacles config tentacles_config = [ commons_profile_data.TentaclesData( name=index_trading.IndexTradingMode.get_name(), config=trading_mode_config ) ] # Test parameters exchange = "binance" starting_funds = 10000.0 backtesting_start_time_delta = 86400.0 # 1 day in seconds # Call the function result = indexes_configuration.create_index_config_from_tentacles_config( tentacles_config, exchange, starting_funds, backtesting_start_time_delta ) # Assertions assert isinstance(result, commons_profile_data.ProfileData) assert result.profile_details.name == "serverless" assert result.trading.reference_market == "USDC" # binance default assert result.trading.risk == 0.5 assert len(result.exchanges) == 1 assert result.exchanges[0].internal_name == exchange assert result.exchanges[0].exchange_type == commons_constants.CONFIG_EXCHANGE_SPOT # Check currencies (BTC and ETH, not USD which should be replaced by reference market) assert len(result.crypto_currencies) == 2 trading_pairs = {curr.name: curr.trading_pairs for curr in result.crypto_currencies} assert ["BTC/USDC"] == trading_pairs["BTC"] assert ["ETH/USDC"] == trading_pairs["ETH"] # Check trader settings assert result.trader.enabled is True # Check tentacles config assert len(result.tentacles) == 1 assert result.tentacles[0].name == index_trading.IndexTradingMode.get_name() assert index_trading.IndexTradingModeProducer.INDEX_CONTENT in result.tentacles[0].config # Check that USD was replaced by reference market in distribution distribution_names = [ item[index_distribution.DISTRIBUTION_NAME] for item in result.tentacles[0].config[index_trading.IndexTradingModeProducer.INDEX_CONTENT] ] assert "USD" not in distribution_names assert "USDC" in distribution_names # binance's reference market # Check backtesting config assert result.backtesting_context is not None assert [exchange] == result.backtesting_context.exchanges assert result.backtesting_context.start_time_delta == backtesting_start_time_delta assert {"USDC": starting_funds} == result.backtesting_context.starting_portfolio def test_generate_index_config(): # Create test distribution distribution = [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 50.0, }, { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 30.0, }, { index_distribution.DISTRIBUTION_NAME: "USDT", index_distribution.DISTRIBUTION_VALUE: 20.0, }, ] # Test parameters rebalance_cap = 5.0 selected_rebalance_trigger_profile = "profile1" rebalance_trigger_profiles = [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.0, }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 10.0, }, ] reference_market = "USDT" exchange = "binance" min_funds = 1000.0 coins_by_symbol = { "BTC": "BTC", "ETH": "ETH", "USDT": "USDT", } disabled_backtesting = False backtesting_start_time_delta = 172800.0 # 2 days in seconds # Call the function result = indexes_configuration.generate_index_config( distribution, rebalance_cap, selected_rebalance_trigger_profile, rebalance_trigger_profiles, reference_market, exchange, min_funds, coins_by_symbol, disabled_backtesting, backtesting_start_time_delta ) # Assertions - check that result is a dict assert isinstance(result, dict) # Check profile details assert "profile_details" in result assert result["profile_details"]["name"] == "serverless" # Check trading config assert "trading" in result assert result["trading"]["reference_market"] == reference_market assert result["trading"]["risk"] == 0.5 # Check exchanges assert "exchanges" in result assert len(result["exchanges"]) == 1 assert result["exchanges"][0]["internal_name"] == exchange # Check crypto currencies (should not include reference market) assert "crypto_currencies" in result assert len(result["crypto_currencies"]) == 2 # BTC and ETH, not USDT (reference market) trading_pairs = {curr["name"]: curr["trading_pairs"] for curr in result["crypto_currencies"]} assert ["BTC/USDT"] == trading_pairs["BTC"] assert ["ETH/USDT"] == trading_pairs["ETH"] # Check trader assert "trader" in result assert result["trader"]["enabled"] is True # Check tentacles assert "tentacles" in result assert len(result["tentacles"]) == 1 tentacle_config = result["tentacles"][0] assert tentacle_config["name"] == index_trading.IndexTradingMode.get_name() assert "config" in tentacle_config # Check index trading config config = tentacle_config["config"] assert config[index_trading.IndexTradingModeProducer.INDEX_CONTENT] == distribution assert config[index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] == rebalance_cap assert config[index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE] == selected_rebalance_trigger_profile assert config[index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES] == rebalance_trigger_profiles assert config[index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY] == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value assert config[index_trading.IndexTradingModeProducer.SELL_UNINDEXED_TRADED_COINS] is True assert config[index_trading.IndexTradingModeProducer.REFRESH_INTERVAL] == 1 # Check backtesting config assert "backtesting_context" in result backtesting = result["backtesting_context"] assert backtesting["exchanges"] == [exchange] assert backtesting["start_time_delta"] == backtesting_start_time_delta assert {"USDT": min_funds * 10} == backtesting["starting_portfolio"] # Test with disabled backtesting result_no_backtesting = indexes_configuration.generate_index_config( distribution, rebalance_cap, selected_rebalance_trigger_profile, rebalance_trigger_profiles, reference_market, exchange, min_funds, coins_by_symbol, True, backtesting_start_time_delta ) assert "exchanges" not in result_no_backtesting["backtesting_context"] ================================================ FILE: Meta/Keywords/scripting_library/tests/configuration/test_profile_data_configuration.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading import octobot_commons.constants as commons_constants import octobot_commons.profiles.profile_data as commons_profile_data import tentacles.Meta.Keywords.scripting_library as scripting_library def test_register_historical_configs_adds_traded_pairs(): # Master has no traded pairs, historical has one master = scripting_library.minimal_profile_data() tentacle_name = "TestTentacle" master.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config={})] # Historical profile with a traded pair historical = scripting_library.minimal_profile_data() historical.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config={})] scripting_library.add_traded_symbols(historical, ["BTC/USDT"]) historicals = {1000.0: historical} assert [] == scripting_library.get_traded_symbols(master) scripting_library.register_historical_configs(master, historicals, True, False) # Master should now have the traded pair assert ["BTC/USDT"] == scripting_library.get_traded_symbols(master) def test_register_historical_configs_registers_historical_tentacle_config(): # Master and historical have different tentacle config dicts master = scripting_library.minimal_profile_data() tentacle_name = "TestTentacle" master_config = {"foo": 1} master.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=master_config)] historical_1 = scripting_library.minimal_profile_data() hist_config_1 = {"foo": 2} historical_1.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=hist_config_1)] historical_2 = scripting_library.minimal_profile_data() hist_config_2 = {"foo": 3} historical_2.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=hist_config_2)] historicals = {1000.0: historical_1, 2000.0: historical_2} scripting_library.register_historical_configs(master, historicals, False, False) # Master config should now have a historical config registered assert commons_constants.CONFIG_HISTORICAL_CONFIGURATION in master_config assert len(master_config[commons_constants.CONFIG_HISTORICAL_CONFIGURATION]) == 2 assert master_config[commons_constants.CONFIG_HISTORICAL_CONFIGURATION][0][0] == 2000.0 assert master_config[commons_constants.CONFIG_HISTORICAL_CONFIGURATION][0][1] == hist_config_2 assert master_config[commons_constants.CONFIG_HISTORICAL_CONFIGURATION][1][0] == 1000.0 assert master_config[commons_constants.CONFIG_HISTORICAL_CONFIGURATION][1][1] == hist_config_1 def test_register_historical_configs_applies_master_edits(): # Master has a config with a special field, historical does not master = scripting_library.minimal_profile_data() tentacle_name = "TestTentacle" special_key = "special" master_config = { special_key: 42, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "plop1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "plop1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 4 }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "plop2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20 } ], index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value } master.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=master_config)] historical_1 = scripting_library.minimal_profile_data() hist_config_1 = {} historical_1.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=hist_config_1)] historical_2 = scripting_library.minimal_profile_data() hist_config_2 = {special_key: 1} historical_2.tentacles = [commons_profile_data.TentaclesData(name=tentacle_name, config=hist_config_2)] historicals = {1000.0: historical_1, 2000.0: historical_2} scripting_library.register_historical_configs(master, historicals, False, True) # no update as tentacle_name is not configurable tentacles and config keys assert hist_config_1 == {} assert hist_config_2 == {special_key: 1} # now using IndexTradingMode: a whitelisted tentacle for profile_data in (master, historical_1, historical_2): profile_data.tentacles[0].name = index_trading.IndexTradingMode.get_name() scripting_library.register_historical_configs(master, historicals, False, True) # configurable tentacles abd config keys are applied to historical configs assert hist_config_1 == { index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "plop1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "plop1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 4 }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "plop2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20 } ], index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value } assert hist_config_2 == { special_key: 1, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "plop1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "plop1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 4 }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "plop2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20 } ], index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value } ================================================ FILE: Meta/Keywords/scripting_library/tests/exchanges/__init__.py ================================================ import os import pathlib import pytest import pytest_asyncio import octobot_commons.constants as commons_constants import octobot_backtesting.backtesting as backtesting import octobot_backtesting.constants as backtesting_constants import octobot_backtesting.time as backtesting_time import octobot_trading.exchanges as exchanges from octobot_commons.tests.test_config import load_test_config pytestmark = pytest.mark.asyncio DEFAULT_EXCHANGE_NAME = "binance" TEST_CONFIG_FOLDER = pathlib.Path(os.path.abspath(__file__)).parent.parent @pytest_asyncio.fixture async def backtesting_config(request): config = load_test_config(test_folder=TEST_CONFIG_FOLDER) config[backtesting_constants.CONFIG_BACKTESTING] = {} config[backtesting_constants.CONFIG_BACKTESTING][commons_constants.CONFIG_ENABLED_OPTION] = True if hasattr(request, "param"): ref_market = request.param config[commons_constants.CONFIG_TRADING][commons_constants.CONFIG_TRADER_REFERENCE_MARKET] = ref_market return config @pytest_asyncio.fixture async def fake_backtesting(backtesting_config): return backtesting.Backtesting( config=backtesting_config, exchange_ids=[], matrix_id="", backtesting_files=[], ) @pytest_asyncio.fixture async def backtesting_exchange_manager(request, backtesting_config, fake_backtesting): config = None exchange_name = DEFAULT_EXCHANGE_NAME is_spot = True is_margin = False is_future = False if hasattr(request, "param"): config, exchange_name, is_spot, is_margin, is_future = request.param if config is None: config = backtesting_config exchange_manager_instance = exchanges.ExchangeManager(config, exchange_name) exchange_manager_instance.is_backtesting = True exchange_manager_instance.is_spot_only = is_spot exchange_manager_instance.is_margin = is_margin exchange_manager_instance.is_future = is_future exchange_manager_instance.use_cached_markets = False exchange_manager_instance.backtesting = fake_backtesting exchange_manager_instance.backtesting.time_manager = backtesting_time.TimeManager(config) await exchange_manager_instance.initialize(exchange_config_by_exchange=None) yield exchange_manager_instance await exchange_manager_instance.stop() @pytest_asyncio.fixture async def backtesting_trader(backtesting_config, backtesting_exchange_manager): trader_instance = exchanges.TraderSimulator(backtesting_config, backtesting_exchange_manager) await trader_instance.initialize() return backtesting_config, backtesting_exchange_manager, trader_instance ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/__init__.py ================================================ # Copyright ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/__init__.py ================================================ # Copyright ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_create_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import decimal import os import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.position_size as position_size import tentacles.Meta.Keywords.scripting_library.orders.grouping as grouping import octobot_trading.enums as trading_enums import octobot_trading.errors as errors import octobot_trading.constants as trading_constants import octobot_trading.personal_data as trading_personal_data import octobot_trading.modes.script_keywords as script_keywords from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context, mock_context, symbol_market, \ skip_if_octobot_trading_mocking_disabled from tentacles.Meta.Keywords.scripting_library.tests.exchanges import backtesting_trader, backtesting_config, \ backtesting_exchange_manager, fake_backtesting # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_create_order_instance(mock_context): with mock.patch.object(create_order, "_get_order_quantity_and_side", mock.AsyncMock(return_value=(decimal.Decimal(1), "sell"))) \ as _get_order_quantity_and_side_mock, \ mock.patch.object(create_order, "_get_order_details", mock.AsyncMock(return_value=(1, 2, 3, 4, 5, 6, 7, 8, 9))) \ as _get_order_details_mock, \ mock.patch.object(script_keywords, "get_price_with_offset", mock.AsyncMock(return_value=42)) as get_offset_mock, \ mock.patch.object(create_order, "_create_order", mock.AsyncMock()) as _create_order_mock: with mock.patch.object(create_order, "_paired_order_is_closed", mock.Mock(return_value=True)) \ as _paired_order_is_closed_mock: order = mock.Mock(is_open=mock.Mock(return_value=False)) assert [] == await create_order.create_order_instance( mock_context, "side", "symbol", "order_amount", "order_target_position", "stop_loss_offset", "stop_loss_tag", "stop_loss_type", "stop_loss_group", "take_profit_offset", "take_profit_tag", "take_profit_type", "take_profit_group", "order_type_name", "order_offset", "order_min_offset", "order_max_offset", "order_limit_offset", "slippage_limit", "time_limit", "reduce_only", "post_only", "tag", "group", [order]) _paired_order_is_closed_mock.assert_called_once_with(mock_context, "group") _get_order_quantity_and_side_mock.assert_not_called() _get_order_details_mock.assert_not_called() get_offset_mock.assert_not_called() _create_order_mock.assert_not_called() with mock.patch.object(create_order, "_paired_order_is_closed", mock.Mock(return_value=False)) \ as _paired_order_is_closed_mock: order = mock.Mock(is_open=mock.Mock(return_value=False)) await create_order.create_order_instance( mock_context, "side", "symbol", "order_amount", "order_target_position", "stop_loss_offset", "stop_loss_tag", "stop_loss_type", "stop_loss_group", "take_profit_offset", "take_profit_tag", "take_profit_type", "take_profit_group", "order_type_name", "order_offset", "order_min_offset", "order_max_offset", "order_limit_offset", "slippage_limit", "time_limit", "reduce_only", "post_only", "tag", "group", [order]) _paired_order_is_closed_mock.assert_called_once_with(mock_context, "group") _get_order_quantity_and_side_mock.assert_called_once_with(mock_context, "order_amount", "order_target_position", "order_type_name", "side", "reduce_only", False) _get_order_details_mock.assert_called_once_with(mock_context, "order_type_name", "sell", "order_offset", "reduce_only", "order_limit_offset") assert get_offset_mock.call_count == 2 _create_order_mock.assert_called_once_with( context=mock_context, symbol="symbol", order_quantity=decimal.Decimal(1), order_price=2, tag="tag", order_type_name="order_type_name", input_side="side", side="sell", final_side=3, order_type=1, order_min_offset="order_min_offset", max_offset_val=7, reduce_only=4, group="group", stop_loss_price=42, stop_loss_tag="stop_loss_tag", stop_loss_type="stop_loss_type", stop_loss_group="stop_loss_group", take_profit_price=42, take_profit_tag="take_profit_tag", take_profit_type="take_profit_type", take_profit_group="take_profit_group", wait_for=[order], truncate=False, order_amount='order_amount', order_target_position='order_target_position') async def test_paired_order_is_closed(mock_context, skip_if_octobot_trading_mocking_disabled): # skip_if_octobot_trading_mocking_disabled oco_group, "get_group_open_orders" assert create_order._paired_order_is_closed(mock_context, None) is False oco_group = grouping.create_one_cancels_the_other_group(mock_context) assert create_order._paired_order_is_closed(mock_context, oco_group) is False order = mock.Mock() order_2 = mock.Mock() order_2.is_closed = mock.Mock(return_value=True) if os.getenv('CYTHON_IGNORE'): return with mock.patch.object(oco_group, "get_group_open_orders", mock.Mock(return_value=[order, order_2])) as \ get_group_open_orders_mock: with mock.patch.object(order, "is_closed", mock.Mock(return_value=True)) as is_closed_mock: assert create_order._paired_order_is_closed(mock_context, oco_group) is True is_closed_mock.assert_called_once() get_group_open_orders_mock.assert_called_once() get_group_open_orders_mock.reset_mock() with mock.patch.object(order, "is_closed", mock.Mock(return_value=False)) as is_closed_mock: assert create_order._paired_order_is_closed(mock_context, oco_group) is False is_closed_mock.assert_called_once() get_group_open_orders_mock.assert_called_once() order.order_group = None null_context.just_created_orders = [order] with mock.patch.object(order, "is_closed", mock.Mock(return_value=True)) as is_closed_mock: assert create_order._paired_order_is_closed(null_context, oco_group) is False is_closed_mock.assert_not_called() order.order_group = oco_group assert create_order._paired_order_is_closed(null_context, oco_group) is True is_closed_mock.assert_called_once() order.order_group = mock.Mock() is_closed_mock.reset_mock() assert create_order._paired_order_is_closed(null_context, oco_group) is False is_closed_mock.assert_not_called() async def test_use_total_holding(): with mock.patch.object(create_order, "_is_stop_order", mock.Mock(return_value=False)) as _is_stop_order_mock: assert create_order._use_total_holding("type") is False _is_stop_order_mock.assert_called_once_with("type") with mock.patch.object(create_order, "_is_stop_order", mock.Mock(return_value=True)) as _is_stop_order_mock: assert create_order._use_total_holding("type2") is True _is_stop_order_mock.assert_called_once_with("type2") async def test_is_stop_order(): assert create_order._is_stop_order("") is False assert create_order._is_stop_order("market") is False assert create_order._is_stop_order("limit") is False assert create_order._is_stop_order("stop_loss") is True assert create_order._is_stop_order("stop_market") is True assert create_order._is_stop_order("stop_limit") is True assert create_order._is_stop_order("trailing_stop_loss") is True assert create_order._is_stop_order("trailing_market") is False assert create_order._is_stop_order("trailing_limit") is False async def test_get_order_quantity_and_side(null_context): # order_amount and order_target_position are both not set with pytest.raises(errors.InvalidArgumentError): await create_order._get_order_quantity_and_side(null_context, None, None, "", "", True, False) # order_amount and order_target_position are set with pytest.raises(errors.InvalidArgumentError): await create_order._get_order_quantity_and_side(null_context, 1, 2, "", "", True, False) # order_amount but no side with pytest.raises(errors.InvalidArgumentError): await create_order._get_order_quantity_and_side(null_context, 1, None, "", None, True, False) with pytest.raises(errors.InvalidArgumentError): await create_order._get_order_quantity_and_side(null_context, 1, None, "", "fsdsfds", True, True), False with mock.patch.object(position_size, "get_amount", mock.AsyncMock(return_value=decimal.Decimal(1))) as get_amount_mock: with mock.patch.object(create_order, "_use_total_holding", mock.Mock(return_value=False)) as _use_total_holding_mock, \ mock.patch.object(create_order, "_is_stop_order", mock.Mock(return_value=False)) as _is_stop_order_mock: assert await create_order._get_order_quantity_and_side(null_context, 1, None, "", "sell", True, False) \ == (decimal.Decimal(1), "sell") get_amount_mock.assert_called_once_with(null_context, 1, "sell", True, False, use_total_holding=False, unknown_portfolio_on_creation=False) get_amount_mock.reset_mock() _is_stop_order_mock.assert_called_once_with("") _use_total_holding_mock.assert_called_once_with("") with mock.patch.object(create_order, "_use_total_holding", mock.Mock(return_value=True)) as _use_total_holding_mock, \ mock.patch.object(create_order, "_is_stop_order", mock.Mock(return_value=True)) as _is_stop_order_mock: assert await create_order._get_order_quantity_and_side(null_context, 1, None, "order_type", "sell", False, True) \ == (decimal.Decimal(1), "sell") get_amount_mock.assert_called_once_with(null_context, 1, "sell", False, True, use_total_holding=True, unknown_portfolio_on_creation=True) get_amount_mock.reset_mock() _is_stop_order_mock.assert_called_once_with("order_type") _use_total_holding_mock.assert_called_once_with("order_type") with mock.patch.object(position_size, "get_target_position", mock.AsyncMock(return_value=(decimal.Decimal(10), "buy"))) as get_target_position_mock: with mock.patch.object(create_order, "_use_total_holding", mock.Mock(return_value=True)) as _use_total_holding_mock, \ mock.patch.object(create_order, "_is_stop_order", mock.Mock(return_value=False)) as _is_stop_order_mock: assert await create_order._get_order_quantity_and_side(null_context, None, 1, "order_type", None, True, False) \ == (decimal.Decimal(10), "buy") get_target_position_mock.assert_called_once_with(null_context, 1, True, False, use_total_holding=True, unknown_portfolio_on_creation=False) get_target_position_mock.reset_mock() _is_stop_order_mock.assert_called_once_with("order_type") _use_total_holding_mock.assert_called_once_with("order_type") with mock.patch.object(create_order, "_use_total_holding", mock.Mock(return_value=False)) as _use_total_holding_mock, \ mock.patch.object(create_order, "_is_stop_order", mock.Mock(return_value=True)) as _is_stop_order_mock: assert await create_order._get_order_quantity_and_side(null_context, None, 1, "order_type", None, False, True) \ == (decimal.Decimal(10), "buy") get_target_position_mock.assert_called_once_with(null_context, 1, False, True, use_total_holding=False, unknown_portfolio_on_creation=True) get_target_position_mock.reset_mock() _is_stop_order_mock.assert_called_once_with("order_type") _use_total_holding_mock.assert_called_once_with("order_type") async def test_get_order_details(null_context): ten = decimal.Decimal(10) with mock.patch.object(script_keywords, "get_price_with_offset", mock.AsyncMock(return_value=ten)) as get_offset_mock: async def _test_market(side, expected_order_type): order_type, order_price, side, _, _, _, _, _, _ = await create_order._get_order_details( null_context, "market", side, None, None, None ) assert order_type is expected_order_type assert order_price == ten assert side is None get_offset_mock.assert_called_once_with(null_context, "0") get_offset_mock.reset_mock() await _test_market(trading_enums.TradeOrderSide.SELL.value, trading_enums.TraderOrderType.SELL_MARKET) await _test_market(trading_enums.TradeOrderSide.BUY.value, trading_enums.TraderOrderType.BUY_MARKET) async def _test_limit(side, expected_order_type): order_type, order_price, side, _, _, _, _, _, _ = await create_order._get_order_details( null_context, "limit", side, "25%", None, None ) assert order_type is expected_order_type assert order_price == ten assert side is None get_offset_mock.assert_called_once_with(null_context, "25%") get_offset_mock.reset_mock() await _test_limit(trading_enums.TradeOrderSide.SELL.value, trading_enums.TraderOrderType.SELL_LIMIT) await _test_limit(trading_enums.TradeOrderSide.BUY.value, trading_enums.TraderOrderType.BUY_LIMIT) async def _test_stop_loss(side, expected_side): order_type, order_price, side, _, _, _, _, _, _ = await create_order._get_order_details( null_context, "stop_loss", side, "25%", None, None ) assert order_type is trading_enums.TraderOrderType.STOP_LOSS assert order_price == ten assert side is expected_side get_offset_mock.assert_called_once_with(null_context, "25%") get_offset_mock.reset_mock() await _test_stop_loss(trading_enums.TradeOrderSide.SELL.value, trading_enums.TradeOrderSide.SELL) await _test_stop_loss(trading_enums.TradeOrderSide.BUY.value, trading_enums.TradeOrderSide.BUY) async def _test_trailing_market(side, expected_side): order_type, order_price, side, _, trailing_method, _, _, _, _ = await create_order._get_order_details( null_context, "trailing_market", side, "25%", None, None ) assert order_type is trading_enums.TraderOrderType.TRAILING_STOP assert trailing_method == "continuous" assert order_price == ten assert side is expected_side get_offset_mock.assert_called_once_with(null_context, "25%") get_offset_mock.reset_mock() await _test_trailing_market(trading_enums.TradeOrderSide.SELL.value, trading_enums.TradeOrderSide.SELL) await _test_trailing_market(trading_enums.TradeOrderSide.BUY.value, trading_enums.TradeOrderSide.BUY) async def _test_trailing_limit(side, expected_side): order_type, order_price, side, _, trailing_method, min_offset_val, max_offset_val, _, _ \ = await create_order._get_order_details( null_context, "trailing_limit", side, "25%", None, None ) assert order_type is trading_enums.TraderOrderType.TRAILING_STOP_LIMIT assert trailing_method == "continuous" assert order_price is None assert side is expected_side assert min_offset_val == ten assert max_offset_val == ten assert get_offset_mock.call_count == 2 get_offset_mock.reset_mock() await _test_trailing_limit(trading_enums.TradeOrderSide.SELL.value, trading_enums.TradeOrderSide.SELL) await _test_trailing_limit(trading_enums.TradeOrderSide.BUY.value, trading_enums.TradeOrderSide.BUY) async def test_create_order(mock_context, symbol_market): with mock.patch.object(trading_personal_data, "get_pre_order_data", mock.AsyncMock(return_value=(None, None, decimal.Decimal(5), decimal.Decimal(105), symbol_market))) \ as get_pre_order_data_mock, \ mock.patch.object(create_order, "_get_group_adapted_quantity", mock.Mock(return_value=decimal.Decimal(1))) \ as _get_group_adapted_quantity_mock: # without linked orders # don't plot orders mock_context.plot_orders = False orders = await create_order._create_order( mock_context, "BTC/USDT", decimal.Decimal(1), decimal.Decimal(100), "tag", "order_type_name", "input_side", trading_enums.TradeOrderSide.BUY.value, None, trading_enums.TraderOrderType.BUY_MARKET, None, None, False, None, None, None, None, None, None, None, None, None, None, None, True, None) assert get_pre_order_data_mock.call_count == 2 _get_group_adapted_quantity_mock.assert_called_once_with(mock_context, None, trading_enums.TraderOrderType.BUY_MARKET, decimal.Decimal(1)) assert len(orders) == 1 assert isinstance(orders[0], trading_personal_data.BuyMarketOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].tag == "tag" assert orders[0].origin_price == decimal.Decimal(105) assert orders[0].origin_quantity == decimal.Decimal(1) assert mock_context.just_created_orders == orders mock_context.just_created_orders = [] get_pre_order_data_mock.reset_mock() _get_group_adapted_quantity_mock.reset_mock() # with order group # plot orders mock_context.plot_orders = True oco_group = grouping.create_one_cancels_the_other_group(mock_context) orders = await create_order._create_order( mock_context, "BTC/USDT", decimal.Decimal(1), decimal.Decimal(100), "tag2", "order_type_name", "input_side", trading_enums.TradeOrderSide.BUY.value, None, trading_enums.TraderOrderType.TRAILING_STOP, decimal.Decimal(5), None, False, oco_group, None, None, None, None, None, None, None, None, None, True, None, None) get_pre_order_data_mock.assert_called_once_with(mock_context.exchange_manager, symbol="BTC/USDT", timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT) _get_group_adapted_quantity_mock.assert_called_once_with(mock_context, oco_group, trading_enums.TraderOrderType.TRAILING_STOP, decimal.Decimal(1)) assert len(orders) == 1 assert isinstance(orders[0], trading_personal_data.TrailingStopOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].tag == "tag2" assert orders[0].origin_price == decimal.Decimal(100) assert orders[0].origin_quantity == decimal.Decimal(1) assert orders[0].trader == mock_context.trader assert orders[0].trailing_percent == decimal.Decimal(5) assert orders[0].order_group is oco_group assert mock_context.just_created_orders == orders mock_context.just_created_orders = [] get_pre_order_data_mock.reset_mock() _get_group_adapted_quantity_mock.reset_mock() # with same order group as one previously created order: group them together oco_group = grouping.create_one_cancels_the_other_group(mock_context) previous_orders = [trading_personal_data.LimitOrder(mock_context.trader), trading_personal_data.LimitOrder(mock_context.trader)] previous_orders[0].add_to_order_group(oco_group) # with mock.patch.object(create_order, "pre_initialize_order_callback", mock.AsyncMock()) \ # as pre_initialize_order_callback_mock: mock_context.plot_orders = False orders = await create_order._create_order( mock_context, "BTC/USDT", decimal.Decimal(1), decimal.Decimal(100), "tag2", "order_type_name", "side", trading_enums.TradeOrderSide.BUY.value, trading_enums.TradeOrderSide.BUY, trading_enums.TraderOrderType.TRAILING_STOP, decimal.Decimal(5), None, True, oco_group, None, None, None, None, None, None, None, None, None, True, None, None) get_pre_order_data_mock.assert_called_once_with(mock_context.exchange_manager, symbol="BTC/USDT", timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT) _get_group_adapted_quantity_mock.assert_called_once_with(mock_context, oco_group, trading_enums.TraderOrderType.TRAILING_STOP, decimal.Decimal(1)) assert len(orders) == 1 assert isinstance(orders[0], trading_personal_data.TrailingStopOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].tag == "tag2" assert orders[0].origin_price == decimal.Decimal(100) assert orders[0].origin_quantity == decimal.Decimal(1) assert orders[0].trader == mock_context.trader assert orders[0].trailing_percent == decimal.Decimal(5) assert orders[0].order_group is oco_group assert orders[0].side is trading_enums.TradeOrderSide.BUY assert mock_context.just_created_orders == orders mock_context.just_created_orders = [] grouped_orders = grouping.get_open_orders_from_group(oco_group) assert len(grouped_orders) == 1 # only order this order got created and therefore is open in group assert grouped_orders[0] is orders[0] async def test_get_group_adapted_quantity(mock_context, skip_if_octobot_trading_mocking_disabled): # skip_if_octobot_trading_mocking_disabled btps_group, "can_create_order" oco_group = grouping.create_one_cancels_the_other_group(mock_context) # no filter on oco groups assert create_order._get_group_adapted_quantity(mock_context, oco_group, "whatever", decimal.Decimal(1000000)) \ == decimal.Decimal(1000000) btps_group = grouping.create_balanced_take_profit_and_stop_group(mock_context) if os.getenv('CYTHON_IGNORE'): return with mock.patch.object(btps_group, "can_create_order", mock.Mock(return_value=False)) as can_create_order_mock, \ mock.patch.object(btps_group, "get_max_order_quantity", mock.Mock(return_value=decimal.Decimal(1))) \ as get_max_order_quantity_mock: # no context.just_created_orders: never block 1st orders to create as they can't be balanced assert create_order._get_group_adapted_quantity(mock_context, btps_group, "whatever", decimal.Decimal(100)) \ == decimal.Decimal(100) can_create_order_mock.assert_not_called() get_max_order_quantity_mock.assert_not_called() order_1 = mock.Mock(order_group=oco_group, order_type=trading_enums.TraderOrderType.STOP_LOSS) mock_context.just_created_orders.append(order_1) # context.just_created_orders has orders from other groups: consider this one as 1st from the group assert create_order._get_group_adapted_quantity(mock_context, btps_group, "whatever", decimal.Decimal(100)) \ == decimal.Decimal(100) can_create_order_mock.assert_not_called() get_max_order_quantity_mock.assert_not_called() order_2 = mock.Mock(order_group=btps_group, order_type=trading_enums.TraderOrderType.SELL_LIMIT) mock_context.just_created_orders.append(order_2) # only take profits being created: allow it assert create_order._get_group_adapted_quantity(mock_context, btps_group, trading_enums.TraderOrderType.SELL_LIMIT, decimal.Decimal(10)) \ == decimal.Decimal(10) can_create_order_mock.assert_not_called() get_max_order_quantity_mock.assert_not_called() # imbalanced orders: call can_create_order to figure out if we can create this order assert create_order._get_group_adapted_quantity(mock_context, btps_group, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, decimal.Decimal(10)) == decimal.Decimal(1) can_create_order_mock.assert_called_once_with(trading_enums.TraderOrderType.STOP_LOSS_LIMIT, decimal.Decimal(10)) get_max_order_quantity_mock.assert_called_once_with(trading_enums.TraderOrderType.STOP_LOSS_LIMIT) ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_limit_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.order_types.limit_order as limit_order from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_limit(null_context): with mock.patch.object(create_order, "create_order_instance", mock.AsyncMock()) as create_order_instance: await limit_order.limit(null_context, "side", "symbol", "amount", "target_position", "offset", "stop_loss_offset", "stop_loss_tag", "stop_loss_type", "stop_loss_group", "take_profit_offset", "take_profit_tag", "take_profit_type", "take_profit_group", "slippage_limit", "time_limit", "reduce_only", "post_only", "tag", "group", "wait_for") create_order_instance.assert_called_once_with( null_context, side="side", symbol="symbol", order_amount="amount", order_target_position="target_position", stop_loss_offset="stop_loss_offset", stop_loss_tag="stop_loss_tag", stop_loss_type="stop_loss_type", stop_loss_group="stop_loss_group", take_profit_offset="take_profit_offset", take_profit_tag="take_profit_tag", take_profit_type="take_profit_type", take_profit_group="take_profit_group", order_type_name="limit", order_offset="offset", slippage_limit="slippage_limit", time_limit="time_limit", reduce_only="reduce_only", post_only="post_only", tag="tag", group="group", wait_for="wait_for") ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_market_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.order_types.market_order as market_order from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_market(null_context): with mock.patch.object(create_order, "create_order_instance", mock.AsyncMock()) as create_order_instance: await market_order.market(null_context, "side", "symbol", "amount", "target_position", "stop_loss_offset", "stop_loss_tag", "stop_loss_type", "stop_loss_group", "take_profit_offset", "take_profit_tag", "take_profit_type", "take_profit_group", "reduce_only", "tag", "group", "wait_for") create_order_instance.assert_called_once_with( null_context, side="side", symbol="symbol", order_amount="amount", order_target_position="target_position", stop_loss_offset="stop_loss_offset", stop_loss_tag="stop_loss_tag", stop_loss_type="stop_loss_type", stop_loss_group="stop_loss_group", take_profit_offset="take_profit_offset", take_profit_tag="take_profit_tag", take_profit_type="take_profit_type", take_profit_group="take_profit_group", order_type_name="market", reduce_only="reduce_only", tag="tag", group="group", wait_for="wait_for") ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_multiple_orders_creation.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import pytest import mock import decimal import contextlib import os import octobot_trading.personal_data as trading_personal_data import octobot_trading.personal_data.orders.order_util as order_util import octobot_trading.api as api import octobot_trading.errors as errors import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import tentacles.Meta.Keywords.scripting_library as scripting_library from tentacles.Meta.Keywords.scripting_library.tests import event_loop, mock_context, \ skip_if_octobot_trading_mocking_disabled from tentacles.Meta.Keywords.scripting_library.tests.exchanges import backtesting_trader, backtesting_config, \ backtesting_exchange_manager, fake_backtesting import tentacles.Meta.Keywords.scripting_library.tests.test_utils.order_util as test_order_util # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_orders_with_invalid_values(mock_context, skip_if_octobot_trading_mocking_disabled): # skip_if_octobot_trading_mocking_disabled mock_context.trader, "create_order" initial_usdt_holdings, btc_price = await _usdt_trading_context(mock_context) if os.getenv('CYTHON_IGNORE'): return with mock.patch.object(trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)), \ mock.patch.object(order_util, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)), \ mock.patch.object(mock_context.trader, "create_order", mock.AsyncMock()) as create_order_mock: with pytest.raises(errors.InvalidArgumentError): # no amount await scripting_library.market( mock_context, side="buy" ) create_order_mock.assert_not_called() create_order_mock.reset_mock() with pytest.raises(errors.InvalidArgumentError): # negative amount await scripting_library.market( mock_context, amount="-1", side="buy" ) create_order_mock.assert_not_called() create_order_mock.reset_mock() with pytest.raises(errors.InvalidArgumentError): # missing offset parameter await scripting_library.limit( mock_context, target_position="20%", side="buy" ) with pytest.raises(errors.InvalidArgumentError): # missing side parameter await scripting_library.market( mock_context, amount="1" ) # orders without having enough funds for amount, side in ((1, "sell"), (0.000000001, "buy")): await scripting_library.market( mock_context, amount=amount, side=side ) create_order_mock.assert_not_called() create_order_mock.reset_mock() mock_context.orders_writer.log_many.assert_not_called() mock_context.orders_writer.log_many.reset_mock() mock_context.logger.warning.assert_called_once() mock_context.logger.warning.reset_mock() @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_orders_amount_then_position_sequence(mock_context): initial_usdt_holdings, btc_price = await _usdt_trading_context(mock_context) mock_context.exchange_manager.is_future = True api.load_pair_contract( mock_context.exchange_manager, api.create_default_future_contract( mock_context.symbol, decimal.Decimal(1), trading_enums.FutureContractType.LINEAR_PERPETUAL, trading_constants.DEFAULT_SYMBOL_POSITION_MODE ).to_dict() ) if os.getenv('CYTHON_IGNORE'): return with mock.patch.object(trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)), \ mock.patch.object(order_util, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)): # buy for 10% of the total portfolio value orders = await scripting_library.market( mock_context, amount="10%", side="buy" ) btc_val = decimal.Decimal(10) # 10.00 usdt_val = decimal.Decimal(45000) # 45000.00 await _fill_and_check(mock_context, btc_val, usdt_val, orders) # buy for 10% of the portfolio available value orders = await scripting_library.limit( mock_context, amount="10%a", offset="0", side="buy" ) btc_val = btc_val + decimal.Decimal(str((45000 * decimal.Decimal("0.1")) / 500)) # 19.0 usdt_val = usdt_val * decimal.Decimal(str(0.9)) # 40500.00 await _fill_and_check(mock_context, btc_val, usdt_val, orders) # buy for for 10% of the current position value orders = await scripting_library.market( mock_context, amount="10%p", side="buy" ) usdt_val = usdt_val - (btc_val * decimal.Decimal("0.1") * btc_price) # 39550.00 btc_val = btc_val * decimal.Decimal("1.1") # 20.90 await _fill_and_check(mock_context, btc_val, usdt_val, orders) # price changes to 1000 btc_price = 1000 mock_context.exchange_manager.exchange_personal_data.portfolio_manager.handle_mark_price_update( "BTC/USDT", btc_price) with mock.patch.object(trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)), \ mock.patch.object(order_util, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)): # buy to reach a target position of 25 btc orders = await scripting_library.market( mock_context, target_position=25 ) usdt_val = usdt_val - ((25 - btc_val) * btc_price) # 35450.00 btc_val = decimal.Decimal(25) # 25 await _fill_and_check(mock_context, btc_val, usdt_val, orders) # buy to reach a target position of 60% of the total portfolio (in BTC) orders = await scripting_library.limit( mock_context, target_position="60%", offset=0 ) previous_btc_val = btc_val btc_val = (btc_val + (usdt_val / btc_price)) * decimal.Decimal("0.6") # 36.27 usdt_val = usdt_val - (btc_val - previous_btc_val) * btc_price # 24180.00 await _fill_and_check(mock_context, btc_val, usdt_val, orders) # buy to reach a target position including an additional 50% of the available USDT in BTC orders = await scripting_library.market( mock_context, target_position="50%a" ) btc_val = btc_val + usdt_val / 2 / btc_price # 48.36 usdt_val = usdt_val / 2 # 12090.00 await _fill_and_check(mock_context, btc_val, usdt_val, orders) # sell to keep only 10% of the position, sell at 2000 (1000 + 100%) orders = await scripting_library.limit( mock_context, target_position="10%p", offset="100%" ) usdt_val = usdt_val + btc_val * decimal.Decimal("0.9") * (btc_price * 2) # 99138.00 btc_val = btc_val / 10 # 4.836 await _fill_and_check(mock_context, btc_val, usdt_val, orders) @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_concurrent_orders(mock_context): async with _20_percent_position_trading_context(mock_context) as context_data: btc_val, usdt_val, btc_price = context_data # create 3 sell orders (at price = 500 + 10 = 510) # that would end up selling more than what we have if not executed sequentially # 1st order is 80% of available btc, second is 80% of the remaining 20% and so on orders = [] async def create_order(amount): orders.append( (await scripting_library.limit( mock_context, amount=amount, offset=10, side="sell" ))[0] ) await asyncio.gather( *( create_order("80%a") for _ in range(3) ) ) initial_btc_holdings = btc_val btc_val = initial_btc_holdings * (decimal.Decimal("0.2") ** 3) usdt_val = usdt_val + (initial_btc_holdings - btc_val) * (btc_price + 10) # 50118.40 await _fill_and_check(mock_context, btc_val, usdt_val, orders, orders_count=3) # create 3 buy orders (at price = 500 + 10 = 510) all of them for a target position of 10% # first order gets created to have this 10% position, others are also created like this, ending up in a 30% # position # update portfolio current value mock_context.exchange_manager.exchange_personal_data.portfolio_manager.handle_balance_updated() orders = [] async def create_order(target_position): orders.append( (await scripting_library.limit( mock_context, target_position=target_position, offset=10 ))[0] ) await asyncio.gather( *( create_order("10%") for _ in range(3) ) ) initial_btc_holdings = btc_val # 0.16 initial_total_val = initial_btc_holdings * btc_price + usdt_val initial_position_percent = decimal.Decimal(initial_btc_holdings * btc_price / initial_total_val) btc_val = initial_btc_holdings + \ initial_total_val * (decimal.Decimal("0.1") - initial_position_percent) * 3 / btc_price # 29.79904 usdt_val = usdt_val - (btc_val - initial_btc_holdings) * (btc_price + 10) # 35002.4896 await _fill_and_check(mock_context, btc_val, usdt_val, orders, orders_count=3) @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_sell_limit_with_stop_loss_orders_single_sell_and_stop_with_oco_group(mock_context): async with _20_percent_position_trading_context(mock_context) as context_data: btc_val, usdt_val, btc_price = context_data mock_context.allow_artificial_orders = True # make stop loss not lock funds oco_group = scripting_library.create_one_cancels_the_other_group(mock_context) sell_limit_orders = await scripting_library.limit( mock_context, target_position="0%", offset=50, group=oco_group ) # add_to_order_group(oco_group, sell_limit_orders) stop_loss_orders = await scripting_library.stop_loss( mock_context, target_position="0%", offset=-75, group=oco_group ) assert len(sell_limit_orders) == len(stop_loss_orders) == 1 # stop order is filled usdt_val = usdt_val + btc_val * (btc_price - 75) # 48500.00 btc_val = trading_constants.ZERO # 0.00 await _fill_and_check(mock_context, btc_val, usdt_val, stop_loss_orders, logged_orders_count=2) # linked order is cancelled assert sell_limit_orders[0].is_cancelled() @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_sell_limit_with_stop_loss_orders_two_sells_and_stop_with_oco(mock_context): async with _20_percent_position_trading_context(mock_context) as context_data: btc_val, usdt_val, btc_price = context_data mock_context.allow_artificial_orders = True # make stop loss not lock funds oco_group = scripting_library.create_one_cancels_the_other_group(mock_context) stop_loss_orders = await scripting_library.stop_loss( mock_context, target_position="0%", offset=-50, side="sell", group=oco_group, tag="exitPosition" ) take_profit_limit_orders_1 = await scripting_library.limit( mock_context, target_position="50%p", offset=50 ) take_profit_limit_orders_2 = await scripting_library.limit( mock_context, target_position="0%p", offset=100, group=oco_group, tag="exitPosition" ) # take_profit_limit_orders_1 filled available_btc_val = trading_constants.ZERO # 10.00 total_btc_val = btc_val / 2 # 10.00 usdt_val = usdt_val + btc_val / 2 * (btc_price + 50) # 40000.00 await _fill_and_check(mock_context, available_btc_val, usdt_val, take_profit_limit_orders_1, btc_total=total_btc_val) # linked order is not cancelled assert stop_loss_orders[0].is_open() # take_profit_limit_orders_2 filled usdt_val = usdt_val + btc_val / 2 * (btc_price + 100) # 40000.00 btc_val = trading_constants.ZERO # 0.00 await _fill_and_check(mock_context, btc_val, usdt_val, take_profit_limit_orders_2) # linked order is cancelled assert stop_loss_orders[0].is_cancelled() @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_sell_limit_with_multiple_stop_loss_and_sell_orders_in_balanced_take_profit_and_stop_group(mock_context): async with _20_percent_position_trading_context(mock_context) as context_data: btc_val, usdt_val, btc_price = context_data mock_context.allow_artificial_orders = True # make stop loss not lock funds btsl_group_1 = scripting_library.create_balanced_take_profit_and_stop_group(mock_context) g1_stop_1 = await scripting_library.stop_loss( mock_context, amount="2", offset=-50, side="sell", group=btsl_group_1, tag="exitPosition1" ) g1_stop_2 = await scripting_library.stop_loss( mock_context, amount="3", offset=-100, side="sell", group=btsl_group_1, tag="exitPosition1" ) g1_stop_3 = await scripting_library.stop_loss( mock_context, amount="4", offset=-150, side="sell", group=btsl_group_1, tag="exitPosition1" ) g1_tp_1 = await scripting_library.limit( mock_context, amount="4", offset=50, side="sell", group=btsl_group_1, tag="exitPosition1" ) g1_tp_2 = await scripting_library.limit( mock_context, amount="5", offset=100, side="sell", group=btsl_group_1, tag="exitPosition1" ) btsl_group_2 = scripting_library.create_balanced_take_profit_and_stop_group(mock_context) g2_stop_1 = await scripting_library.stop_loss( mock_context, amount="5", offset=-50, side="sell", group=btsl_group_2, tag="exitPosition1" ) g2_tp_1 = await scripting_library.limit( mock_context, amount="3", offset=50, side="sell", group=btsl_group_2, tag="exitPosition1" ) g2_tp_2 = await scripting_library.limit( mock_context, amount="2", offset=100, side="sell", group=btsl_group_2, tag="exitPosition1" ) # g1_tp_1 filled available_btc_val = decimal.Decimal(6) sold_btc = decimal.Decimal(4) total_btc_val = btc_val - sold_btc usdt_val = usdt_val + sold_btc * (btc_price + 50) await _fill_and_check(mock_context, available_btc_val, usdt_val, g1_tp_1, btc_total=total_btc_val) # g1_stop_3 is cancelled (same size), other are untouched assert g1_stop_3[0].is_cancelled() assert all(o[0].is_open() for o in [g1_stop_1, g1_stop_2, g1_tp_2, g2_stop_1, g2_tp_1, g2_tp_2]) # g1_stop_1 filled sold_btc = decimal.Decimal(2) total_btc_val = total_btc_val - sold_btc usdt_val = usdt_val + sold_btc * (btc_price - 50) await _fill_and_check(mock_context, available_btc_val, usdt_val, g1_stop_1, btc_total=total_btc_val) # g1_tp_1 is edited (reduced size), other are untouched assert g1_tp_2[0].origin_quantity == decimal.Decimal(3) # 5 - 2 assert all(o[0].is_open() for o in [g1_stop_2, g1_tp_2, g2_stop_1, g2_tp_1, g2_tp_2]) # g2_stop_1 filled sold_btc = decimal.Decimal(5) total_btc_val = total_btc_val - sold_btc usdt_val = usdt_val + sold_btc * (btc_price - 50) await _fill_and_check(mock_context, available_btc_val, usdt_val, g2_stop_1, btc_total=total_btc_val) # g1_tp_1 is edited (reduced size), other are untouched assert all(o[0].is_cancelled() for o in [g2_tp_1, g2_tp_2]) assert all(o[0].is_open() for o in [g1_stop_2, g1_tp_2]) # g1_stop_2 cancelled await mock_context.trader.cancel_order(g1_stop_2[0]) # g1_tp_2 is cancelled as well assert all(o[0].is_cancelled() for o in [g1_stop_2, g1_tp_2]) assert scripting_library.get_open_orders(mock_context) == [] @pytest.mark.parametrize("backtesting_config", ["USDT"], indirect=["backtesting_config"]) async def test_multiple_sell_limit_with_stop_loss_rounding_issues_in_balanced_take_profit_and_stop_group(mock_context): async with _20_percent_position_trading_context(mock_context) as context_data: btc_val, usdt_val, btc_price = context_data mock_context.allow_artificial_orders = True # make stop loss not lock funds btsl_group_1 = scripting_library.create_balanced_take_profit_and_stop_group(mock_context) # disable to create orders await btsl_group_1.enable(False) position_size = decimal.Decimal(20) added_amount = decimal.Decimal("0.00100001111") market_1 = await scripting_library.market(mock_context, amount=added_amount, side="buy") assert market_1[0].is_filled() amount = position_size + decimal.Decimal("0.00100001") # ending "111" got truncated assert api.get_portfolio_currency(mock_context.exchange_manager, "BTC").total == amount assert api.get_portfolio_currency(mock_context.exchange_manager, "BTC").available == amount g1_stop_1 = await scripting_library.stop_loss( mock_context, amount=amount, offset=-50, side="sell", group=btsl_group_1, tag="exitPosition1" ) g1_tp_1 = await scripting_library.limit( mock_context, amount=amount * decimal.Decimal("0.5"), offset=50, side="sell", group=btsl_group_1, reduce_only=True ) g1_tp_2 = await scripting_library.limit( mock_context, amount=amount * decimal.Decimal("0.5"), offset=100, side="sell", group=btsl_group_1, reduce_only=True ) assert g1_stop_1[0].origin_quantity == amount assert g1_tp_1[0].origin_quantity == decimal.Decimal('10.00050001') assert g1_tp_2[0].origin_quantity == decimal.Decimal('10.00050000') # enable order group: no order edit is triggered as scripting_library took care of the rounding issue of # 20.00100001 / 2 await btsl_group_1.enable(False) assert g1_stop_1[0].origin_quantity == amount assert g1_tp_1[0].origin_quantity == decimal.Decimal('10.00050001') assert g1_tp_2[0].origin_quantity == decimal.Decimal('10.00050000') async def _usdt_trading_context(mock_context): initial_usdt_holdings = 50000 mock_context.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.update_portfolio_from_balance({ 'BTC': {'available': decimal.Decimal(0), 'total': decimal.Decimal(0)}, 'ETH': {'available': decimal.Decimal(0), 'total': decimal.Decimal(0)}, 'USDT': {'available': decimal.Decimal(str(initial_usdt_holdings)), 'total': decimal.Decimal(str(initial_usdt_holdings))} }, mock_context.exchange_manager) mock_context.exchange_manager.exchange_personal_data.portfolio_manager.handle_balance_updated() btc_price = 500 mock_context.exchange_manager.exchange_personal_data.portfolio_manager.handle_mark_price_update( "BTC/USDT", btc_price) return initial_usdt_holdings, btc_price @contextlib.asynccontextmanager async def _20_percent_position_trading_context(mock_context): initial_usdt_holdings, btc_price = await _usdt_trading_context(mock_context) usdt_val = decimal.Decimal(str(initial_usdt_holdings)) with mock.patch.object(trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)), \ mock.patch.object(order_util, "get_up_to_date_price", mock.AsyncMock(return_value=btc_price)): # initial limit buy order: buy with 20% of portfolio buy_limit_orders = await scripting_library.limit( mock_context, target_position="20%", offset=0, side="buy" ) btc_val = (usdt_val * decimal.Decimal("0.2")) / btc_price # 20.00 usdt_val = usdt_val * decimal.Decimal("0.8") # 40000.00 # position size = 20 BTC await _fill_and_check(mock_context, btc_val, usdt_val, buy_limit_orders) yield btc_val, usdt_val, btc_price async def _fill_and_check(mock_context, btc_available, usdt_available, orders, btc_total=None, usdt_total=None, orders_count=1, logged_orders_count=None): for order in orders: if isinstance(order, trading_personal_data.LimitOrder): await test_order_util.fill_limit_or_stop_order(order) elif isinstance(order, trading_personal_data.MarketOrder): await test_order_util.fill_market_order(order) _ensure_orders_validity(mock_context, btc_available, usdt_available, orders, btc_total=btc_total, usdt_total=usdt_total, orders_count=orders_count, logged_orders_count=logged_orders_count) def _ensure_orders_validity(mock_context, btc_available, usdt_available, orders, btc_total=None, usdt_total=None, orders_count=1, logged_orders_count=None): exchange_manager = mock_context.exchange_manager btc_total = btc_total or btc_available usdt_total = usdt_total or usdt_available assert len(orders) == orders_count assert all(isinstance(order, trading_personal_data.Order) for order in orders) assert mock_context.orders_writer.log_many.call_count == logged_orders_count or orders_count mock_context.orders_writer.log_many.reset_mock() mock_context.logger.warning.assert_not_called() mock_context.logger.warning.reset_mock() mock_context.logger.exception.assert_not_called() mock_context.logger.exception.reset_mock() assert api.get_portfolio_currency(exchange_manager, "BTC").available == btc_available assert api.get_portfolio_currency(exchange_manager, "BTC").total == btc_total assert api.get_portfolio_currency(exchange_manager, "USDT").available == usdt_available assert api.get_portfolio_currency(exchange_manager, "USDT").total == usdt_total ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_stop_loss_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.order_types.stop_loss_order as stop_loss_order from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_stop_loss(null_context): with mock.patch.object(create_order, "create_order_instance", mock.AsyncMock()) as create_order_instance: await stop_loss_order.stop_loss(null_context, "side", "symbol", "offset", "amount", "target_position", "tag", "group", "wait_for") create_order_instance.assert_called_once_with( null_context, side="side", symbol="symbol", order_amount="amount", order_target_position="target_position", order_type_name="stop_loss", order_offset="offset", reduce_only=True, tag="tag", group="group", wait_for="wait_for") ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_trailing_limit_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.order_types.trailing_limit_order as trailing_limit_order from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_trailing_limit(null_context): with mock.patch.object(create_order, "create_order_instance", mock.AsyncMock()) as create_order_instance: await trailing_limit_order.trailing_limit(null_context, "side", "symbol", "amount", "target_position", "offset", "min_offset", "max_offset", "slippage_limit", "time_limit", "reduce_only", "post_only", "tag", "group", "wait_for") create_order_instance.assert_called_once_with( null_context, side="side", symbol="symbol", order_amount="amount", order_target_position="target_position", order_type_name="trailing_limit", order_min_offset="min_offset", order_max_offset="max_offset", order_offset="offset", slippage_limit="slippage_limit", time_limit="time_limit", reduce_only="reduce_only", post_only="post_only", tag="tag", group="group", wait_for="wait_for") ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_trailing_market_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.order_types.trailing_market_order as trailing_market_order from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_trailing_market(null_context): with mock.patch.object(create_order, "create_order_instance", mock.AsyncMock()) as create_order_instance: await trailing_market_order.trailing_market(null_context, "side", "symbol", "amount", "target_position", "offset", "reduce_only", "tag", "group", "wait_for") create_order_instance.assert_called_once_with( null_context, side="side", symbol="symbol", order_amount="amount", order_target_position="target_position", order_type_name="trailing_market", order_offset="offset", reduce_only="reduce_only", tag="tag", group="group", wait_for="wait_for") ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/order_types/test_trailing_stop_loss_order.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_types.create_order as create_order import tentacles.Meta.Keywords.scripting_library.orders.order_types.trailing_stop_loss_order as trailing_stop_loss_order from tentacles.Meta.Keywords.scripting_library.tests import event_loop, null_context # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_trailing_stop_loss(null_context): with mock.patch.object(create_order, "create_order_instance", mock.AsyncMock()) as create_order_instance: await trailing_stop_loss_order.trailing_stop_loss(null_context, "side", "symbol", "amount", "target_position", "offset", "reduce_only", "tag", "group", "wait_for") create_order_instance.assert_called_once_with( null_context, side="side", symbol="symbol", order_amount="amount", order_target_position="target_position", order_type_name="trailing_stop_loss", order_offset="offset", reduce_only="reduce_only", tag="tag", group="group", wait_for="wait_for") ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/position_size/__init__.py ================================================ # Copyright ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/position_size/test_target_position.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import decimal import octobot_trading.enums as trading_enums import octobot_trading.errors as errors import octobot_trading.modes.script_keywords as script_keywords import tentacles.Meta.Keywords.scripting_library.orders.position_size.target_position as target_position import tentacles.Meta.Keywords.scripting_library.data.reading.exchange_private_data as exchange_private_data from tentacles.Meta.Keywords.scripting_library.tests import event_loop, mock_context from tentacles.Meta.Keywords.scripting_library.tests.exchanges import backtesting_trader, backtesting_config, \ backtesting_exchange_manager, fake_backtesting def test_get_target_position_side(): assert target_position.get_target_position_side(1) == trading_enums.TradeOrderSide.BUY.value assert target_position.get_target_position_side(-1) == trading_enums.TradeOrderSide.SELL.value with pytest.raises(RuntimeError): target_position.get_target_position_side(0) @pytest.mark.asyncio async def test_get_target_position(mock_context): with pytest.raises(errors.InvalidArgumentError): await target_position.get_target_position(mock_context, "1sdsqdq") # with positive (long) position with mock.patch.object(script_keywords, "adapt_amount_to_holdings", mock.AsyncMock(return_value=decimal.Decimal(1))) as adapt_amount_to_holdings_mock, \ mock.patch.object(exchange_private_data, "open_position_size", mock.Mock(return_value=decimal.Decimal(10))) as open_position_size_mock: with mock.patch.object(script_keywords, "parse_quantity", mock.Mock(return_value=(script_keywords.QuantityType.POSITION_PERCENT, decimal.Decimal(10)))) \ as parse_quantity_mock, \ mock.patch.object(target_position, "get_target_position_side", mock.Mock(return_value=trading_enums.TradeOrderSide.SELL.value)) \ as get_target_position_side_mock: assert await target_position.get_target_position(mock_context, "1", target_price="hello") == \ (decimal.Decimal(1), trading_enums.TradeOrderSide.SELL.value) parse_quantity_mock.assert_called_once_with("1") open_position_size_mock.assert_called_once_with(mock_context) get_target_position_side_mock.assert_called_once_with(decimal.Decimal(-9)) adapt_amount_to_holdings_mock.assert_called_once_with(mock_context, decimal.Decimal(9), trading_enums.TradeOrderSide.SELL.value, False, True, False, target_price="hello") adapt_amount_to_holdings_mock.reset_mock() get_target_position_side_mock.reset_mock() open_position_size_mock.reset_mock() with mock.patch.object(script_keywords, "parse_quantity", mock.Mock(return_value=(script_keywords.QuantityType.PERCENT, decimal.Decimal(110)))) \ as parse_quantity_mock, \ mock.patch.object(script_keywords, "total_account_balance", mock.AsyncMock(return_value=decimal.Decimal(10))) \ as total_account_balance_mock, \ mock.patch.object(target_position, "get_target_position_side", mock.Mock(return_value=trading_enums.TradeOrderSide.BUY.value)) \ as get_target_position_side_mock: assert await target_position.get_target_position(mock_context, "1", use_total_holding=True, reduce_only=False, is_stop_order=True) == \ (decimal.Decimal(1), trading_enums.TradeOrderSide.BUY.value) parse_quantity_mock.assert_called_once_with("1") total_account_balance_mock.assert_called_once_with(mock_context) open_position_size_mock.assert_called_once_with(mock_context) get_target_position_side_mock.assert_called_once_with(decimal.Decimal(1)) adapt_amount_to_holdings_mock.assert_called_once_with(mock_context, decimal.Decimal(1), trading_enums.TradeOrderSide.BUY.value, True, False, True, target_price=None) adapt_amount_to_holdings_mock.reset_mock() get_target_position_side_mock.reset_mock() open_position_size_mock.reset_mock() with mock.patch.object(script_keywords, "parse_quantity", mock.Mock(return_value=(script_keywords.QuantityType.FLAT, decimal.Decimal(-3)))) \ as parse_quantity_mock, \ mock.patch.object(target_position, "get_target_position_side", mock.Mock(return_value=trading_enums.TradeOrderSide.SELL.value)) \ as get_target_position_side_mock: assert await target_position.get_target_position(mock_context, "1") == \ (decimal.Decimal(1), trading_enums.TradeOrderSide.SELL.value) parse_quantity_mock.assert_called_once_with("1") open_position_size_mock.assert_called_once_with(mock_context) get_target_position_side_mock.assert_called_once_with(decimal.Decimal(-13)) adapt_amount_to_holdings_mock.assert_called_once_with(mock_context, decimal.Decimal(13), trading_enums.TradeOrderSide.SELL.value, False, True, False, target_price=None) adapt_amount_to_holdings_mock.reset_mock() get_target_position_side_mock.reset_mock() open_position_size_mock.reset_mock() with mock.patch.object(script_keywords, "parse_quantity", mock.Mock(return_value=(script_keywords.QuantityType.AVAILABLE_PERCENT, decimal.Decimal(25)))) \ as parse_quantity_mock, \ mock.patch.object(script_keywords, "available_account_balance", mock.AsyncMock(return_value=decimal.Decimal(5))) \ as available_account_balance_mock, \ mock.patch.object(target_position, "get_target_position_side", mock.Mock(return_value=trading_enums.TradeOrderSide.BUY.value)) \ as get_target_position_side_mock: assert await target_position.get_target_position(mock_context, "1") == \ (decimal.Decimal(1), trading_enums.TradeOrderSide.BUY.value) parse_quantity_mock.assert_called_once_with("1") available_account_balance_mock.assert_called_once_with(mock_context, reduce_only=True) # we are at initially at 10, we want add 20% of 5 => need to buy 1.25 get_target_position_side_mock.assert_called_once_with(decimal.Decimal("1.25")) adapt_amount_to_holdings_mock.assert_called_once_with(mock_context, decimal.Decimal(1.25), trading_enums.TradeOrderSide.BUY.value, False, True, False, target_price=None) adapt_amount_to_holdings_mock.reset_mock() get_target_position_side_mock.reset_mock() open_position_size_mock.reset_mock() # with negative (short) position with mock.patch.object(script_keywords, "adapt_amount_to_holdings", mock.AsyncMock(return_value=decimal.Decimal(2))) as adapt_amount_to_holdings_mock, \ mock.patch.object(exchange_private_data, "open_position_size", mock.Mock(return_value=decimal.Decimal(-10))) as open_position_size_mock: with mock.patch.object(script_keywords, "parse_quantity", mock.Mock(return_value=(script_keywords.QuantityType.DELTA, decimal.Decimal(-3)))) \ as parse_quantity_mock, \ mock.patch.object(target_position, "get_target_position_side", mock.Mock(return_value=trading_enums.TradeOrderSide.BUY.value)) \ as get_target_position_side_mock: assert await target_position.get_target_position(mock_context, "1") == \ (decimal.Decimal(2), trading_enums.TradeOrderSide.BUY.value) parse_quantity_mock.assert_called_once_with("1") open_position_size_mock.assert_called_once_with(mock_context) get_target_position_side_mock.assert_called_once_with(decimal.Decimal(7)) adapt_amount_to_holdings_mock.assert_called_once_with(mock_context, decimal.Decimal(7), trading_enums.TradeOrderSide.BUY.value, False, True, False, target_price=None) adapt_amount_to_holdings_mock.reset_mock() get_target_position_side_mock.reset_mock() open_position_size_mock.reset_mock() with mock.patch.object(script_keywords, "parse_quantity", mock.Mock(return_value=(script_keywords.QuantityType.POSITION_PERCENT, decimal.Decimal(-3)))) \ as parse_quantity_mock, \ mock.patch.object(target_position, "get_target_position_side", mock.Mock(return_value=trading_enums.TradeOrderSide.BUY.value)) \ as get_target_position_side_mock: assert await target_position.get_target_position(mock_context, "1") == \ (decimal.Decimal(2), trading_enums.TradeOrderSide.BUY.value) parse_quantity_mock.assert_called_once_with("1") open_position_size_mock.assert_called_once_with(mock_context) get_target_position_side_mock.assert_called_once_with(decimal.Decimal("10.3")) adapt_amount_to_holdings_mock.assert_called_once_with(mock_context, decimal.Decimal("10.3"), trading_enums.TradeOrderSide.BUY.value, False, True, False, target_price=None) adapt_amount_to_holdings_mock.reset_mock() get_target_position_side_mock.reset_mock() open_position_size_mock.reset_mock() ================================================ FILE: Meta/Keywords/scripting_library/tests/orders/test_cancelling.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import tentacles.Meta.Keywords.scripting_library.orders.order_tags as order_tags import tentacles.Meta.Keywords.scripting_library.orders.cancelling as cancelling import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants from tentacles.Meta.Keywords.scripting_library.tests import event_loop, mock_context, \ skip_if_octobot_trading_mocking_disabled from tentacles.Meta.Keywords.scripting_library.tests.exchanges import backtesting_trader, backtesting_config, \ backtesting_exchange_manager, fake_backtesting # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_cancel_orders(mock_context, skip_if_octobot_trading_mocking_disabled): # skip_if_octobot_trading_mocking_disabled mock_context.trader, "cancel_order" tagged_orders = ["order_1", "order_2"] with mock.patch.object(mock_context.trader, "cancel_order", mock.AsyncMock(return_value=True)) as cancel_order_mock, \ mock.patch.object(mock_context.trader, "cancel_open_orders", mock.AsyncMock(return_value=(True, []))) \ as cancel_open_orders_mock: with mock.patch.object(order_tags, "get_tagged_orders", mock.Mock(return_value=tagged_orders)) \ as get_tagged_orders_mock: # cancel all orders from context symbol assert await cancelling.cancel_orders(mock_context) is True get_tagged_orders_mock.assert_not_called() cancel_order_mock.assert_not_called() cancel_open_orders_mock.assert_called_once_with( mock_context.symbol, cancel_loaded_orders=True, side=None, since=trading_constants.NO_DATA_LIMIT, until=trading_constants.NO_DATA_LIMIT ) cancel_open_orders_mock.reset_mock() # cancel sided orders from context symbol side_str_to_side = { "sell": trading_enums.TradeOrderSide.SELL, "buy": trading_enums.TradeOrderSide.BUY, "all": None, } for side, value in side_str_to_side.items(): assert await cancelling.cancel_orders(mock_context, which=side, cancel_loaded_orders=False) is True get_tagged_orders_mock.assert_not_called() cancel_order_mock.assert_not_called() cancel_open_orders_mock.assert_called_once_with( mock_context.symbol, cancel_loaded_orders=False, side=value, since=trading_constants.NO_DATA_LIMIT, until=trading_constants.NO_DATA_LIMIT ) cancel_open_orders_mock.reset_mock() # different symbol values assert await cancelling.cancel_orders(mock_context, symbol="ETH/USDT") is True get_tagged_orders_mock.assert_not_called() cancel_order_mock.assert_not_called() cancel_open_orders_mock.assert_called_once_with( "ETH/USDT", cancel_loaded_orders=True, side=value, since=trading_constants.NO_DATA_LIMIT, until=trading_constants.NO_DATA_LIMIT ) cancel_open_orders_mock.reset_mock() assert await cancelling.cancel_orders(mock_context, symbols=["ETH/USDT", "USDT/USDC"]) is True get_tagged_orders_mock.assert_not_called() cancel_order_mock.assert_not_called() assert cancel_open_orders_mock.mock_calls[0].args == ("ETH/USDT", ) assert cancel_open_orders_mock.mock_calls[1].args == ("USDT/USDC", ) cancel_open_orders_mock.reset_mock() # tags assert await cancelling.cancel_orders(mock_context, which="tag1") is True get_tagged_orders_mock.assert_called_once_with( mock_context, "tag1", symbol=None, since=trading_constants.NO_DATA_LIMIT, until=trading_constants.NO_DATA_LIMIT ) assert cancel_order_mock.mock_calls[0].args == ("order_1", ) assert cancel_order_mock.mock_calls[1].args == ("order_2", ) cancel_open_orders_mock.assert_not_called() cancel_order_mock.reset_mock() # no order to cancel with mock.patch.object(order_tags, "get_tagged_orders", mock.Mock(return_value=[])) as get_tagged_orders_mock: assert await cancelling.cancel_orders(mock_context, which="tag1") is False get_tagged_orders_mock.assert_called_once_with( mock_context, "tag1", symbol=None, since=trading_constants.NO_DATA_LIMIT, until=trading_constants.NO_DATA_LIMIT ) cancel_order_mock.assert_not_called() cancel_open_orders_mock.assert_not_called() ================================================ FILE: Meta/Keywords/scripting_library/tests/static/config.json ================================================ { "time_frame": ["1h", "4h", "1d"], "exchanges": { "binance": { "api-key": "", "api-secret": "", "web-socket": false }, "bitmex": { "api-key": "", "api-secret": "", "web-socket": false }, "poloniex": { "api-key": "", "api-secret": "", "web-socket": false } } } ================================================ FILE: Meta/Keywords/scripting_library/tests/static/profile.json ================================================ { "profile": { "avatar": "default_profile.png", "description": "OctoBot default profile.", "id": "default", "name": "default" }, "config": { "crypto-currencies": { "Bitcoin": { "pairs": [ "BTC/USDT", "BTC/EUR", "BTC/USDC" ] }, "Neo": { "pairs": [ "NEO/BTC" ] }, "Ethereum": { "pairs": [ "ETH/USDT" ] }, "Icon": { "pairs": [ "ICX/BTC" ] }, "VeChain": { "pairs": [ "VEN/BTC" ] }, "Nano": { "pairs": [ "XRB/BTC" ] }, "Cardano": { "pairs": [ "ADA/BTC" ] }, "Ontology": { "pairs": [ "ONT/BTC" ] }, "Stellar": { "pairs": [ "XLM/BTC" ] }, "Power Ledger": { "pairs": [ "POWR/BTC" ] }, "Ethereum Classic": { "pairs": [ "ETC/BTC" ] }, "WAX": { "pairs": [ "WAX/BTC" ] }, "XRP": { "pairs": [ "XRP/BTC" ] }, "Verge": { "pairs": [ "XVG/BTC" ] } }, "exchanges": {}, "trading": { "risk": 1, "reference-market": "BTC" }, "trader": { "enabled": false }, "trader-simulator": { "enabled": true, "starting-portfolio": { "BTC": 10, "USDT": 1000 } } } } ================================================ FILE: Meta/Keywords/scripting_library/tests/test_utils/__init__.py ================================================ ================================================ FILE: Meta/Keywords/scripting_library/tests/test_utils/order_util.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from octobot_commons.asyncio_tools import wait_asyncio_next_cycle async def fill_limit_or_stop_order(limit_or_stop_order): await limit_or_stop_order.on_fill() await wait_asyncio_next_cycle() async def fill_market_order(market_order): await market_order.on_fill() await wait_asyncio_next_cycle() ================================================ FILE: README.md ================================================ # OctoBot-Tentacles [![OctoBot-Tentacles-CI](https://github.com/Drakkar-Software/OctoBot-Tentacles/workflows/OctoBot-Tentacles-CI/badge.svg)](https://github.com/Drakkar-Software/OctoBot-Tentacles/actions) This repository contains default evaluators, strategies, utilitary modules, interfaces and trading modes for the [OctoBot](https://github.com/Drakkar-Software/OctoBot) project. Modules in this tentacles are installed in the **Default** folder of the associated module types To add custom tentacles to your OctoBot, see the [dedicated docs page](https://www.octobot.cloud/guides/octobot-tentacles-development/customize-your-octobot?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=octobot_tentacles_readme). ## Contributing to the official OctoBot Tentacles: 1. Create your own fork of this repo 2. Start your branch from the `dev` branch of this repo 3. Commit and push your changes into your fork 4. Create a pull request from your branch on your fork to the `dev` branch of this repo Tips: To export changes from your local OctoBot tentacles folder into this repo, run this command from your OctoBot folder: `python start.py tentacles -e "../../OctoBot-Tentacles" OctoBot-Default-Tentacles -d "tentacles"` Where: - `start.py`: start.py script from your OctoBot folder - `tentacles`: the tentacles command of the script - `../../OctoBot-Tentacles`: the path to your fork of this repository (relatively to the folder you are running the command from) - `OctoBot-Default-Tentacles`: filter to only export tentacles tagged as `OctoBot-Default-Tentacles` (in metadata file) - `-d tentacles`: name of your OctoBot tentacles folder that are to be copied to the repo (relatively to the folder you are running the command from) ================================================ FILE: Services/Interfaces/telegram_bot_interface/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .telegram_bot import TelegramBotInterface ================================================ FILE: Services/Interfaces/telegram_bot_interface/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramBotInterface"], "tentacles-requirements": ["telegram_service"] } ================================================ FILE: Services/Interfaces/telegram_bot_interface/telegram_bot.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import logging import time import threading import telegram.ext import telegram.constants import telegram.error import octobot_commons.constants as commons_constants import octobot_services.constants as services_constants import octobot_services.interfaces.bots as interfaces_bots import tentacles.Services.Services_bases as Services_bases # Telegram bot interface # telegram markdown reminder: *bold*, _italic_, `code`, [text_link](http://github.com/) class TelegramBotInterface(interfaces_bots.AbstractBotInterface): REQUIRED_SERVICES = [Services_bases.TelegramService] HANDLED_CHATS = [telegram.constants.ChatType.PRIVATE] LAST_ERROR_TIMESTAMPS = {} ERROR_LEVEL_INTERVALS_THRESHOLD = 1 * commons_constants.MINUTE_TO_SECONDS def __init__(self, config): super().__init__(config) self.telegram_service: Services_bases.TelegramService = None async def _post_initialize(self, _): self.telegram_service = Services_bases.TelegramService.instance() self.telegram_service.register_user(self.get_name()) self.telegram_service.add_handlers(self.get_bot_handlers()) self.telegram_service.add_error_handler(self.command_error) self.telegram_service.register_text_polling_handler(self.HANDLED_CHATS, self.echo) return True async def _inner_start(self) -> bool: if self.telegram_service: await self.telegram_service.start_bot(TelegramBotInterface.handle_polling_error) return True else: # debug level log only: error log is already produced in initialize() self.get_logger().debug( f"Impossible to start bot interface: {self.REQUIRED_SERVICES[0].get_name()} is unavailable." ) return False async def stop(self): await self.telegram_service.stop() def get_bot_handlers(self): return [ telegram.ext.CommandHandler("start", self.command_start), telegram.ext.CommandHandler("ping", self.command_ping), telegram.ext.CommandHandler(["portfolio", "pf"], self.command_portfolio), telegram.ext.CommandHandler(["open_orders", "oo"], self.command_open_orders), telegram.ext.CommandHandler(["trades_history", "th"], self.command_trades_history), telegram.ext.CommandHandler(["profitability", "pb"], self.command_profitability), telegram.ext.CommandHandler(["fees", "fs"], self.command_fees), telegram.ext.CommandHandler("sell_all", self.command_sell_all), telegram.ext.CommandHandler("sell_all_currencies", self.command_sell_all_currencies), telegram.ext.CommandHandler("set_risk", self.command_risk), telegram.ext.CommandHandler(["market_status", "ms"], self.command_market_status), telegram.ext.CommandHandler(["configuration", "cf"], self.command_configuration), telegram.ext.CommandHandler(["refresh_portfolio", "rpf"], self.command_portfolio_refresh), telegram.ext.CommandHandler(["version", "v"], self.command_version), telegram.ext.CommandHandler("stop", self.command_stop), telegram.ext.CommandHandler("restart", self.command_restart), telegram.ext.CommandHandler(["help", "h"], self.command_help), telegram.ext.CommandHandler(["pause", "resume"], self.command_pause_resume), telegram.ext.MessageHandler(telegram.ext.filters.COMMAND, self.command_unknown) ] @staticmethod async def command_unknown(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, f"`Unfortunately, I don't know the command:`" f"{telegram.helpers.escape_markdown(update.effective_message.text)}." ) @staticmethod async def command_help(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): message = "* - My OctoBot skills - *" + interfaces_bots.EOL + interfaces_bots.EOL message += "/start: `Displays my startup message.`" + interfaces_bots.EOL message += "/ping: `Shows for how long I'm working.`" + interfaces_bots.EOL message += "/portfolio or /pf: `Displays my current portfolio.`" + interfaces_bots.EOL message += "/open\_orders or /oo: `Displays my current open orders.`" + interfaces_bots.EOL message += "/trades\_history or /th: `Displays my trades history since I started.`" + interfaces_bots.EOL message += "/profitability or /pb: `Displays the profitability I made since I started.`" + interfaces_bots.EOL message += "/market\_status or /ms: `Displays my understanding of the market and my risk parameter.`" + interfaces_bots.EOL message += "/fees or /fs: `Displays the total amount of fees I paid since I started.`" + interfaces_bots.EOL message += "/configuration or /cf: `Displays my traders, exchanges, evaluators, strategies and trading " \ "mode.`" + interfaces_bots.EOL message += "* - Trading Orders - *" + interfaces_bots.EOL message += "/sell\_all : `Cancels all my orders related to the currency in parameter and instantly " \ "liquidate my holdings in this currency for my reference market.`" + interfaces_bots.EOL message += "/sell\_all\_currencies : `Cancels all my orders and instantly liquidate all my currencies " \ "for my reference market.`" + interfaces_bots.EOL message += "* - Management - *" + interfaces_bots.EOL message += "/set\_risk: `Changes my current risk setting into your command's parameter.`" + interfaces_bots.EOL message += "/refresh\_portfolio or /rpf : `Forces OctoBot's real trader portfolio refresh using exchange " \ "data. Should normally not be necessary.`" + interfaces_bots.EOL message += "/pause or /resume: `Pauses or resumes me.`" + interfaces_bots.EOL message += "/restart: `Restarts me.`" + interfaces_bots.EOL message += "/stop: `Stops me.`" + interfaces_bots.EOL message += "/version or /v: `Displays my current software version.`" + interfaces_bots.EOL message += "/help: `Displays this help.`" await update.effective_message.reply_markdown(message) elif TelegramBotInterface._is_authorized_chat(update): await update.effective_message.reply_text(interfaces_bots.UNAUTHORIZED_USER_MESSAGE) @staticmethod async def command_start(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_start(markdown=True) ) elif TelegramBotInterface._is_authorized_chat(update): await TelegramBotInterface._send_message(update, interfaces_bots.UNAUTHORIZED_USER_MESSAGE) @staticmethod async def command_restart(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message(update, "I'll come back !") threading.Thread( target=interfaces_bots.AbstractBotInterface.set_command_restart, name="Restart bot from telegram command" ).start() @staticmethod async def command_stop(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message(update, "_I'm leaving this world..._") # start interfaces_bots.AbstractBotInterface.set_command_stop in a new thread to finish the current # python-telegram-bot update loop (python-telegram-bot updater can't stop within a loop, therefore # to be able to stop the telegram interface, this command has to return before the telegram bot can # can be stopped, otherwise telegram#stop ends up deadlocking) threading.Thread( target=interfaces_bots.AbstractBotInterface.set_command_stop, name="Stop bot from telegram command" ).start() @staticmethod async def command_version(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, f"`{interfaces_bots.AbstractBotInterface.get_command_version()}`" ) async def command_pause_resume(self, update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): if self.paused: await TelegramBotInterface._send_message( update, f"_Resuming..._{interfaces_bots.EOL}`I will restart trading when I see opportunities !`" ) self.set_command_resume() else: await TelegramBotInterface._send_message( update, f"_Pausing..._{interfaces_bots.EOL}`I'm cancelling my orders.`" ) await self.set_command_pause() @staticmethod async def command_ping(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, f"`{interfaces_bots.AbstractBotInterface.get_command_ping()}`" ) @staticmethod async def command_risk(update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): try: result_risk = interfaces_bots.AbstractBotInterface.set_command_risk(decimal.Decimal(context.args[0])) await TelegramBotInterface._send_message(update, f"`Risk successfully set to {result_risk}.`") except Exception: await TelegramBotInterface._send_message( update, "`Failed to set new risk, please provide a number between 0 and 1.`" ) @staticmethod async def command_profitability(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_profitability(markdown=True) ) @staticmethod async def command_fees(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_fees(markdown=True) ) @staticmethod async def command_sell_all_currencies(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, f"`{await interfaces_bots.AbstractBotInterface.get_command_sell_all_currencies()}`" ) @staticmethod async def command_sell_all(update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): currency = context.args[0] if not currency: await TelegramBotInterface._send_message(update, "`Require a currency in parameter of this command.`") else: await TelegramBotInterface._send_message( update, f"`{await interfaces_bots.AbstractBotInterface.get_command_sell_all(currency)}`" ) @staticmethod async def command_portfolio(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message(update, interfaces_bots.AbstractBotInterface.get_command_portfolio( markdown=True)) @staticmethod async def command_open_orders(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_open_orders(markdown=True) ) @staticmethod async def command_trades_history(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_trades_history(markdown=True) ) # refresh current order lists and portfolios and reload tham from exchanges @staticmethod async def command_portfolio_refresh(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): result = "Refresh" try: await interfaces_bots.AbstractBotInterface.set_command_portfolios_refresh() await TelegramBotInterface._send_message(update, f"`{result} successful`") except Exception as e: await TelegramBotInterface._send_message(update, f"`{result} failure: {e}`") # Displays my trades, exchanges, evaluators, strategies and trading @staticmethod async def command_configuration(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): try: await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_configuration(markdown=True) ) except Exception: await TelegramBotInterface._send_message( update, "`I'm unfortunately currently unable to show you my configuration. " "Please wait for my initialization to complete.`" ) @staticmethod async def command_market_status(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): try: await TelegramBotInterface._send_message( update, interfaces_bots.AbstractBotInterface.get_command_market_status(markdown=True) ) except Exception: await TelegramBotInterface._send_message( update, "`I'm unfortunately currently unable to show you my market evaluations, " "please retry in a few seconds.`" ) @staticmethod async def command_error(update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE): if update is None: TelegramBotInterface.get_logger().error( f"Command error with no telegram update. This should not happen. " f"context.error: {context} context: {context}" ) return TelegramBotInterface.get_logger().warning("Command receiver error. Please check logs for more details.") \ if context.error is None else TelegramBotInterface.get_logger().exception(context.error, False) if update is not None and TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message( update, f"Failed to perform this command {update.effective_message.text} : `{context.error}`" ) @staticmethod def handle_polling_error(error): if isinstance(error, (telegram.error.NetworkError, telegram.error.Conflict)): if isinstance(error, telegram.error.Conflict): error_message = f"The configured Telegram bot is already connected to a different " \ f"software. Please create a different Telegram bot for each of your simultaneous " \ f"OctoBots ({error})" else: error_message = f"Telegram bot error: {error} ({error.__class__.__name__})" if TelegramBotInterface.get_error_log_level(error) is logging.ERROR: TelegramBotInterface.get_logger().error(error_message) elif TelegramBotInterface.get_error_log_level(error) is logging.WARNING: TelegramBotInterface.get_logger().warning(error_message) else: TelegramBotInterface.get_logger().debug(error_message) else: TelegramBotInterface.get_logger().error( f"Unexpected telegram bot error: {error} ({error.__class__.__name__})" ) @staticmethod def get_error_log_level(error): try: if time.time() - TelegramBotInterface.LAST_ERROR_TIMESTAMPS[error.__class__] > \ TelegramBotInterface.ERROR_LEVEL_INTERVALS_THRESHOLD: TelegramBotInterface.LAST_ERROR_TIMESTAMPS[error.__class__] = time.time() return logging.ERROR return logging.DEBUG except KeyError: TelegramBotInterface.LAST_ERROR_TIMESTAMPS[error.__class__] = time.time() return logging.ERROR @staticmethod async def echo(update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): if TelegramBotInterface._is_valid_user(update): await TelegramBotInterface._send_message(update, update.effective_message.text, markdown=False) @staticmethod def enable(config, is_enabled, associated_config=services_constants.CONFIG_TELEGRAM): interfaces_bots.AbstractBotInterface.enable(config, is_enabled, associated_config=associated_config) @staticmethod def is_enabled(config, associated_config=services_constants.CONFIG_TELEGRAM): return interfaces_bots.AbstractBotInterface.is_enabled(config, associated_config=associated_config) @staticmethod def _is_authorized_chat(update: telegram.Update): return update.effective_chat.type in TelegramBotInterface.HANDLED_CHATS @staticmethod def _is_valid_user(update: telegram.Update, associated_config=services_constants.CONFIG_TELEGRAM): # only authorize users from a private chat if not TelegramBotInterface._is_authorized_chat(update): return False is_valid, white_list = interfaces_bots.AbstractBotInterface._is_valid_user( update.effective_chat.username, associated_config=associated_config ) if white_list and not is_valid: TelegramBotInterface.get_logger().error( f"An unauthorized Telegram user is trying to talk to me: username: {update.effective_chat.username}, " f"first_name: {update.effective_chat.first_name}, text: {update.effective_message.text}" ) return is_valid @staticmethod async def _send_message(update: telegram.Update, message: str, markdown=True): messages = interfaces_bots.AbstractBotInterface._split_messages_if_too_long( message, telegram.constants.MessageLimit.MAX_TEXT_LENGTH, interfaces_bots.EOL ) for m in messages: if markdown: await update.effective_message.reply_markdown(m) else: await update.effective_message.reply_text(m) ================================================ FILE: Services/Interfaces/telegram_bot_interface/tests/test_bot_interface.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import asyncio import contextlib import octobot_services.interfaces as interfaces import octobot.octobot as octobot import octobot.constants as octobot_constants import octobot.producers as octobot_producers import octobot.producers as trading_producers import octobot.community as community import octobot_commons.tests.test_config as test_config import octobot_tentacles_manager.loaders as loaders import octobot_evaluators.api as evaluator_api import tests.test_utils.config as test_utils_config # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def create_minimalist_unconnected_octobot(): # import here to prevent later web interface import issues community.IdentifiersProvider.use_production() octobot_instance = octobot.OctoBot(test_config.load_test_config(dict_only=False)) octobot_instance.initialized = True tentacles_config = test_utils_config.load_test_tentacles_config() loaders.reload_tentacle_by_tentacle_class() octobot_instance.task_manager.async_loop = asyncio.get_event_loop() octobot_instance.task_manager.create_pool_executor() octobot_instance.tentacles_setup_config = tentacles_config octobot_instance.configuration_manager.add_element(octobot_constants.TENTACLES_SETUP_CONFIG_KEY, tentacles_config) octobot_instance.exchange_producer = trading_producers.ExchangeProducer(None, octobot_instance, None, False) octobot_instance.evaluator_producer = octobot_producers.EvaluatorProducer(None, octobot_instance) await evaluator_api.initialize_evaluators(octobot_instance.config, tentacles_config) octobot_instance.evaluator_producer.matrix_id = evaluator_api.create_matrix() return octobot_instance # use context manager instead of fixture to prevent pytest threads issues @contextlib.asynccontextmanager async def get_bot_interface(): bot_interface = interfaces.AbstractBotInterface({}) interfaces.AbstractInterface.initialize_global_project_data( (await create_minimalist_unconnected_octobot()).octobot_api, "octobot", "x.y.z-alpha42") yield bot_interface async def test_all_commands(): """ Test basing commands interactions, for most of them a default message will be saying that the bot is not ready. :return: None """ async with get_bot_interface() as bot_interface: assert len(bot_interface.get_command_configuration()) > 50 assert len(bot_interface.get_command_market_status()) > 50 assert len(bot_interface.get_command_trades_history()) > 50 assert len(bot_interface.get_command_open_orders()) > 50 assert len(bot_interface.get_command_fees()) > 50 assert "Decimal" not in bot_interface.get_command_fees() assert "Nothing to sell" in await bot_interface.get_command_sell_all_currencies() assert "Nothing to sell for BTC" in await bot_interface.get_command_sell_all("BTC") with pytest.raises(RuntimeError): await bot_interface.set_command_portfolios_refresh() assert len(bot_interface.get_command_portfolio()) > 50 assert "Decimal" not in bot_interface.get_command_portfolio() assert len(bot_interface.get_command_profitability()) > 50 assert "Decimal" not in bot_interface.get_command_profitability() assert "I'm alive since" in bot_interface.get_command_ping() assert all(elem in bot_interface.get_command_version() for elem in [interfaces.AbstractInterface.project_name, interfaces.AbstractInterface.project_version]) assert "Hello, I'm OctoBot" in bot_interface.get_command_start() assert await bot_interface.set_command_pause() is None ================================================ FILE: Services/Interfaces/web_interface/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import collections import logging import abc import os.path import octobot_commons.logging as bot_logging import octobot_commons.timestamp_util as timestamp_util import octobot_services.enums as services_enums class Notifier: @abc.abstractmethod def send_notifications(self) -> bool: raise NotImplementedError("send_notifications is not implemented") notifiers = {} def register_notifier(notification_key, notifier): if notification_key not in notifiers: notifiers[notification_key] = [] notifiers[notification_key].append(notifier) GENERAL_NOTIFICATION_KEY = "general_notifications" BACKTESTING_NOTIFICATION_KEY = "backtesting_notifications" DATA_COLLECTOR_NOTIFICATION_KEY = "data_collector_notifications" STRATEGY_OPTIMIZER_NOTIFICATION_KEY = "strategy_optimizer_notifications" DASHBOARD_NOTIFICATION_KEY = "dashboard_notifications" import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: # Make WebInterface visible to imports from tentacles.Services.Interfaces.web_interface.web import WebInterface # disable server logging for logger in ('engineio.server', 'socketio.server', 'geventwebsocket.handler'): logging.getLogger(logger).setLevel(logging.WARNING) MAX_NOTIFICATION_HISTORY_SIZE = 1000 MAX_NOTIFICATION_AT_ONCE = 10 notifications_history = collections.deque(maxlen=MAX_NOTIFICATION_HISTORY_SIZE) notifications = collections.deque(maxlen=MAX_NOTIFICATION_AT_ONCE) # Different from notifications_history: this list "should" never be cleared by more recent notifications. # maxsize is here just in case to avoid memory leaks critical_notifications = collections.deque(maxlen=MAX_NOTIFICATION_HISTORY_SIZE) TIME_AXIS_TITLE = "Time" def dir_last_updated(folder): update_times = [ os.path.getmtime(os.path.join(root_path, f)) for root_path, dirs, files in os.walk(folder) for f in files ] return str(max(update_times + [0])) # add 0 not to crash if no files are found LAST_UPDATED_STATIC_FILES = 0 def update_registered_plugins(plugins): global LAST_UPDATED_STATIC_FILES last_update_time = max( float(LAST_UPDATED_STATIC_FILES), float(dir_last_updated(os.path.join(os.path.dirname(__file__), "static"))) ) for plugin in plugins: if plugin.static_folder: last_update_time = max( last_update_time, float(dir_last_updated(plugin.static_folder)) ) LAST_UPDATED_STATIC_FILES = last_update_time def flush_notifications(): notifications.clear() def _send_notification(notification_key, **kwargs) -> bool: if notification_key in notifiers: return any(notifier.all_clients_send_notifications(**kwargs) for notifier in notifiers[notification_key]) return False def send_general_notifications(**kwargs): if _send_notification(GENERAL_NOTIFICATION_KEY, **kwargs): flush_notifications() def send_backtesting_status(**kwargs): _send_notification(BACKTESTING_NOTIFICATION_KEY, **kwargs) def send_data_collector_status(**kwargs): _send_notification(DATA_COLLECTOR_NOTIFICATION_KEY, **kwargs) def send_strategy_optimizer_status(**kwargs): _send_notification(STRATEGY_OPTIMIZER_NOTIFICATION_KEY, **kwargs) def send_new_trade(dict_new_trade, exchange_id, symbol): _send_notification(DASHBOARD_NOTIFICATION_KEY, exchange_id=exchange_id, trades=[dict_new_trade], symbol=symbol) def send_order_update(dict_order, exchange_id, symbol): _send_notification(DASHBOARD_NOTIFICATION_KEY, exchange_id=exchange_id, order=dict_order, symbol=symbol) async def add_notification(level: services_enums.NotificationLevel, title, message, sound=None): notification = { "Level": level.value, "Title": title, "Message": message.replace("
", " "), "Sound": sound, "Time": timestamp_util.get_now_time() } notifications.append(notification) notifications_history.append(notification) if level == services_enums.NotificationLevel.CRITICAL: critical_notifications.append(notification) send_general_notifications() def get_notifications() -> list: return list(notifications) def get_notifications_history() -> list: return list(notifications_history) def get_critical_notifications() -> list: return list(critical_notifications) def get_logs(): return bot_logging.logs_database[bot_logging.LOG_DATABASE] def get_errors_count(): return bot_logging.logs_database[bot_logging.LOG_NEW_ERRORS_COUNT] def flush_errors_count(): bot_logging.reset_errors_count() ================================================ FILE: Services/Interfaces/web_interface/advanced_controllers/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot.enums import tentacles.Services.Interfaces.web_interface.advanced_controllers.configuration import tentacles.Services.Interfaces.web_interface.advanced_controllers.home import tentacles.Services.Interfaces.web_interface.advanced_controllers.matrix import tentacles.Services.Interfaces.web_interface.advanced_controllers.strategy_optimizer import tentacles.Services.Interfaces.web_interface.advanced_controllers.tentacles_management def register(distribution: octobot.enums.OctoBotDistribution): blueprint = flask.Blueprint('advanced', __name__, url_prefix='/advanced', template_folder="../advanced_templates") if distribution is octobot.enums.OctoBotDistribution.DEFAULT: tentacles.Services.Interfaces.web_interface.advanced_controllers.configuration.register(blueprint) tentacles.Services.Interfaces.web_interface.advanced_controllers.home.register(blueprint) tentacles.Services.Interfaces.web_interface.advanced_controllers.matrix.register(blueprint) tentacles.Services.Interfaces.web_interface.advanced_controllers.strategy_optimizer.register(blueprint) tentacles.Services.Interfaces.web_interface.advanced_controllers.tentacles_management.register(blueprint) return blueprint __all__ = [ "register", ] ================================================ FILE: Services/Interfaces/web_interface/advanced_controllers/configuration.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/evaluator_config") @blueprint.route('/evaluator_config', methods=['GET', 'POST']) @login.login_required_when_activated def evaluator_config(): if flask.request.method == 'POST': request_data = flask.request.get_json() success = True response = "" if request_data: # update evaluator config if required if constants.EVALUATOR_CONFIG_KEY in request_data and request_data[constants.EVALUATOR_CONFIG_KEY]: success = success and models.update_tentacles_activation_config( request_data[constants.EVALUATOR_CONFIG_KEY]) response = { "evaluator_updated_config": request_data[constants.EVALUATOR_CONFIG_KEY] } if success: if request_data.get("restart_after_save", False): models.schedule_delayed_command(models.restart_bot) return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply('{"update": "ko"}', 500) else: media_url = flask.url_for("tentacle_media", _external=True) missing_tentacles = set() return flask.render_template( 'advanced_evaluator_config.html', evaluator_config=models.get_evaluator_detailed_config(media_url, missing_tentacles), evaluator_startup_config=models.get_evaluators_tentacles_startup_activation(), missing_tentacles=missing_tentacles, current_profile=models.get_current_profile() ) ================================================ FILE: Services/Interfaces/web_interface/advanced_controllers/home.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/") @blueprint.route("/home") @login.login_required_when_activated def home(): return flask.render_template('advanced_index.html') ================================================ FILE: Services/Interfaces/web_interface/advanced_controllers/matrix.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_services.interfaces.util as util import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/matrix") @login.login_required_when_activated def matrix(): return flask.render_template('advanced_matrix.html', matrix_list=util.get_matrix_list()) ================================================ FILE: Services/Interfaces/web_interface/advanced_controllers/strategy_optimizer.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): # strategy optimize is disabled return @blueprint.route("/strategy-optimizer") @blueprint.route('/strategy-optimizer', methods=['GET', 'POST']) @login.login_required_when_activated def strategy_optimizer(): if not models.is_backtesting_enabled(): return flask.redirect(flask.url_for("home")) if flask.request.method == 'POST': update_type = flask.request.args["update_type"] request_data = flask.request.get_json() success = False reply = "Operation OK" if update_type == "cancel_optimizer": try: success, reply = models.cancel_optimizer() except Exception as e: return util.get_rest_reply('{"start_optimizer": "ko: ' + str(e) + '"}', 500) elif request_data: if update_type == "start_optimizer": try: strategy = request_data["strategy"][0] time_frames = request_data["time_frames"] evaluators = request_data["evaluators"] risks = request_data["risks"] success, reply = models.start_optimizer(strategy, time_frames, evaluators, risks) except Exception as e: return util.get_rest_reply('{"start_optimizer": "ko: ' + str(e) + '"}', 500) if success: return util.get_rest_reply(flask.jsonify(reply)) else: return util.get_rest_reply(reply, 500) elif flask.request.method == 'GET': if flask.request.args: target = flask.request.args["update_type"] if target == "optimizer_results": optimizer_results = models.get_optimizer_results() return flask.jsonify(optimizer_results) if target == "optimizer_report": optimizer_report = models.get_optimizer_report() return flask.jsonify(optimizer_report) if target == "strategy_params": strategy_name = flask.request.args["strategy_name"] params = { "time_frames": list(models.get_time_frames_list(strategy_name)), "evaluators": list(models.get_evaluators_list(strategy_name)) } return flask.jsonify(params) else: trading_mode = models.get_config_activated_trading_mode() strategies = models.get_strategies_list(trading_mode) current_strategy = strategies[0] if strategies else "" return flask.render_template('advanced_strategy_optimizer.html', strategies=strategies, current_strategy=current_strategy, time_frames=models.get_time_frames_list(current_strategy), evaluators=models.get_evaluators_list(current_strategy), risks=models.get_risks_list(), trading_mode=trading_mode.get_name() if trading_mode else None, run_params=models.get_current_run_params()) ================================================ FILE: Services/Interfaces/web_interface/advanced_controllers/tentacles_management.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.models as models import octobot_commons.authentication as authentication import octobot.constants as constants def register(blueprint): @blueprint.route("/tentacles") @login.active_login_required def tentacles(): return flask.render_template("advanced_tentacles.html", tentacles=models.get_tentacles()) def _handle_package_operation(update_type): if update_type == "add_package": request_data = flask.request.get_json() success = False if request_data: version = None url_key = "url" if url_key in request_data: path_or_url = request_data[url_key] version = request_data.get("version", None) action = "register_and_install" else: path_or_url, action = next(iter(request_data.items())) path_or_url = path_or_url.strip() if action == "register_and_install": installation_result = models.install_packages( path_or_url, version, authenticator=authentication.Authenticator.instance()) if installation_result: return util.get_rest_reply(flask.jsonify(installation_result)) else: return util.get_rest_reply('Impossible to install the given tentacles package. ' 'Please see logs for more details.', 500) if not success: return util.get_rest_reply('{"operation": "ko"}', 500) elif update_type in ["install_packages", "update_packages", "reset_packages"]: packages_operation_result = {} if update_type == "install_packages": packages_operation_result = models.install_packages() elif update_type == "update_packages": packages_operation_result = models.update_packages() elif update_type == "reset_packages": packages_operation_result = models.reset_packages() if packages_operation_result: return util.get_rest_reply(flask.jsonify(packages_operation_result)) else: action = update_type.split("_")[0] return util.get_rest_reply(f'Impossible to {action} packages, check the logs for more information.', 500) def _handle_module_operation(update_type): request_data = flask.request.get_json() if request_data: packages_operation_result = {} if update_type == "update_modules": packages_operation_result = models.update_modules(request_data) elif update_type == "uninstall_modules": packages_operation_result = models.uninstall_modules(request_data) if packages_operation_result is not None: return util.get_rest_reply(flask.jsonify(packages_operation_result)) else: action = update_type.split("_")[0] return util.get_rest_reply(f'Impossible to {action} module(s), check the logs for more information.', 500) else: return util.get_rest_reply('{"Need at least one element be selected": "ko"}', 500) def _handle_tentacles_pages_post(update_type): if update_type in ["add_package", "install_packages", "update_packages", "reset_packages"]: return _handle_package_operation(update_type) elif update_type in ["update_modules", "uninstall_modules"]: return _handle_module_operation(update_type) @blueprint.route('/install_official_tentacle_packages', methods=['POST']) @login.login_required_when_activated def install_official_tentacle_packages(use_beta_tentacles): bundle_url = models.get_official_tentacles_url(use_beta_tentacles == "True") packages_operation_result = models.install_packages(path_or_url=bundle_url) if packages_operation_result: return util.get_rest_reply(flask.jsonify(packages_operation_result)) else: return util.get_rest_reply(f'Impossible to install tentacles, check the logs for more information.', 500) @blueprint.route("/tentacle_packages") @blueprint.route('/tentacle_packages', methods=['GET', 'POST']) @login.active_login_required def tentacle_packages(): if flask.request.method == 'POST': if not constants.CAN_INSTALL_TENTACLES: return util.get_rest_reply(f'Impossible to install tentacles on this cloud OctoBot.', 500) update_type = flask.request.args["update_type"] return _handle_tentacles_pages_post(update_type) else: return flask.render_template("advanced_tentacle_packages.html", get_tentacles_packages=models.get_tentacles_packages) ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_evaluator_config.html ================================================ {% extends "advanced_layout.html" %} {% set active_page = "advanced.evaluator_config" %} {% import 'components/config/evaluator_card.html' as m_config_evaluator_card %} {% import 'macros/tentacles.html' as m_tentacles %} {% block additional_style %} {% endblock additional_style %} {% block body %}
{% if not current_profile.read_only %}

{{ m_tentacles.missing_tentacles_warning(missing_tentacles) }}

Technical analysis

{% for evaluator_name, info in evaluator_config["ta"].items() %} {{ m_config_evaluator_card.config_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endfor %}

Social analysis

{% for evaluator_name, info in evaluator_config["social"].items() %} {{ m_config_evaluator_card.config_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endfor %}

Scripted evaluators

{% for evaluator_name, info in evaluator_config["scripted"].items() %} {{ m_config_evaluator_card.config_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endfor %}

Real time analysis

Should only be used with exchanges supporting websocket connection

{% for evaluator_name, info in evaluator_config["real-time"].items() %} {{ m_config_evaluator_card.config_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endfor %}
{% else %}
Current profile is read only. To be able to change the currently enabled evaluators, please duplicate your current profile by using "duplicate" button in profile page using the "Edit profiles" menu.
{% endif %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_index.html ================================================ {% extends "advanced_layout.html" %} {% set active_page = "advanced.home" %} {% block body %}

Welcome to OctoBot's advanced interface

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %}
This interface is providing insights on some OctoBot advanced concepts and should be used once OctoBot basic features are understood.
{% endblock %} ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_layout.html ================================================ {% import 'components/community/user_details.html' as m_user_details %} {% set active_page = active_page|default('advanced.home') -%} {{ active_page.split(".")[-1] | replace("_", " ") | capitalize }} - OctoBot {% block additional_meta %} {% endblock additional_meta %} {% block additional_style %} {% endblock additional_style %} {{ m_user_details.posthog(IS_DEMO, IS_CLOUD, IS_ALLOWING_TRACKING, PH_TRACKING_ID) }}
{% block body %}{% endblock %}
{% include "distributions/default/footer.html" %} {% block additional_scripts %} {% endblock additional_scripts %} {{ m_user_details.user_details( USER_EMAIL, USER_SELECTED_BOT_ID, has_open_source_package, PROFILE_NAME, TRADING_MODE_NAME, EXCHANGE_NAMES, IS_REAL_TRADING ) }} ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_matrix.html ================================================ {% extends "advanced_layout.html" %} {% set active_page = "advanced.matrix" %} {% block body %}

Matrix View

Evaluators :
Time frames :
Symbols :
Exchanges :
{% for exchange, matrix_exchange in matrix_list.items() %} {% for evaluator, matrix_evaluator in matrix_exchange.items() %} {% for symbol, matrix_symbol in matrix_evaluator.items() %} {% if matrix_symbol is iterable and matrix_symbol is not string %} {% for time_frame, eval_note in matrix_symbol.items() %} {% endfor %} {% else %} {% endif %} {% endfor %} {% endfor %} {% endfor %}
Evaluator Value Time frame Symbol Exchange
{{evaluator}} {{eval_note}} {{time_frame}} {{symbol}} {{exchange}}
{{evaluator}} {{matrix_symbol}} {{symbol}} {{exchange}}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_strategy_optimizer.html ================================================ {% extends "advanced_layout.html" %} {% set active_page = "advanced.strategy_optimizer" %} {% block body %}

Strategy optimizer

Number of simulations 0




{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_tentacle_packages.html ================================================ {% extends "advanced_layout.html" %} {% set active_page = "advanced.tentacles" %} {% block body %}

 Tentacle Packages


Registered tentacles packages

{% for package_name, package_path in get_tentacles_packages().items() %} {% endfor %}
Package Name Package origin location
{{package_name}} {{package_path}}

Additional tentacles packages registration


Packages management


{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/advanced_templates/advanced_tentacles.html ================================================ {% extends "advanced_layout.html" %} {% set active_page = "advanced.tentacles" %} {% block body %}

Installed Tentacles

{% for tentacle in tentacles %} {% endfor %}
# Package Name Type Version Action
{{tentacle.origin_package}} {{tentacle.name}} {{tentacle.tentacle_type}} {{tentacle.version}}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/api/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot.enums import tentacles.Services.Interfaces.web_interface.api.config import tentacles.Services.Interfaces.web_interface.api.exchanges import tentacles.Services.Interfaces.web_interface.api.feedback import tentacles.Services.Interfaces.web_interface.api.metadata import tentacles.Services.Interfaces.web_interface.api.trading import tentacles.Services.Interfaces.web_interface.api.user_commands import tentacles.Services.Interfaces.web_interface.api.bots import tentacles.Services.Interfaces.web_interface.api.webhook import tentacles.Services.Interfaces.web_interface.api.tentacles_packages import tentacles.Services.Interfaces.web_interface.api.dsl from tentacles.Services.Interfaces.web_interface.api.webhook import ( has_webhook, register_webhook ) def register(distribution: octobot.enums.OctoBotDistribution): blueprint = flask.Blueprint('api', __name__, url_prefix='/api', template_folder="") if distribution is octobot.enums.OctoBotDistribution.DEFAULT: tentacles.Services.Interfaces.web_interface.api.feedback.register(blueprint) tentacles.Services.Interfaces.web_interface.api.bots.register(blueprint) tentacles.Services.Interfaces.web_interface.api.webhook.register(blueprint) tentacles.Services.Interfaces.web_interface.api.tentacles_packages.register(blueprint) elif distribution is octobot.enums.OctoBotDistribution.MARKET_MAKING: pass # common routes tentacles.Services.Interfaces.web_interface.api.config.register(blueprint) tentacles.Services.Interfaces.web_interface.api.exchanges.register(blueprint) tentacles.Services.Interfaces.web_interface.api.metadata.register(blueprint) tentacles.Services.Interfaces.web_interface.api.trading.register(blueprint) tentacles.Services.Interfaces.web_interface.api.user_commands.register(blueprint) tentacles.Services.Interfaces.web_interface.api.dsl.register(blueprint) return blueprint __all__ = [ "has_webhook", "register_webhook", "register", ] ================================================ FILE: Services/Interfaces/web_interface/api/bots.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot.community as community import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route("/select_bot", methods=['POST']) @login.login_required_when_activated def select_bot(): if not models.can_select_bot(): return util.get_rest_reply(flask.jsonify("Can't select bot on this setup"), 500) models.select_bot(flask.request.get_json()) bot = models.get_selected_user_bot() flask.flash(f"Selected {bot['name']} bot", "success") return flask.jsonify(bot) @blueprint.route("/create_bot", methods=['POST']) @login.login_required_when_activated def create_bot(): if not models.can_select_bot(): return util.get_rest_reply(flask.jsonify("Can't create bot on this setup"), 500) new_bot = models.create_new_bot() models.select_bot(community.CommunityUserAccount.get_bot_id(new_bot)) bot = models.get_selected_user_bot() flask.flash(f"Created and selected {bot['name']} bot", "success") return flask.jsonify(bot) ================================================ FILE: Services/Interfaces/web_interface/api/config.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.authentication import octobot.community.errors import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route('/get_config_currency', methods=["GET"]) @login.login_required_when_activated def get_config_currency(): return flask.jsonify(models.format_config_symbols(interfaces_util.get_edited_config())) @blueprint.route('/get_all_currencies/', methods=["GET"]) @login.login_required_when_activated def get_all_currencies(exchange): return flask.jsonify(models.get_all_currencies([exchange])) @blueprint.route('/get_all_symbols/') @login.login_required_when_activated def get_all_symbols(exchange): return flask.jsonify(models.get_symbol_list([exchange])) @blueprint.route('/set_config_currency', methods=["POST"]) @login.login_required_when_activated def set_config_currency(): request_data = flask.request.get_json() success, reply = models.update_config_currencies( request_data["currencies"], replace=(request_data.get("action", "update") == "replace") ) return util.get_rest_reply(flask.jsonify(reply)) if success else util.get_rest_reply(reply, 500) @blueprint.route('/change_reference_market_on_config_currencies', methods=["POST"]) @login.login_required_when_activated def change_reference_market_on_config_currencies(): request_data = flask.request.get_json() success, reply = models.change_reference_market_on_config_currencies(request_data["old_base_currency"], request_data["new_base_currency"]) return util.get_rest_reply(flask.jsonify(reply)) if success else util.get_rest_reply(reply, 500) @blueprint.route('/display_config', methods=["POST"]) @login.login_required_when_activated def display_config(): request_data = flask.request.get_json() success = False message = "nothing to do" if "color_mode" in request_data: success, message = models.set_color_mode(request_data["color_mode"]) if "time_frame" in request_data: success, message = models.set_display_timeframe(request_data["time_frame"]) if "display_orders" in request_data: success, message = models.set_display_orders(request_data["display_orders"]) return util.get_rest_reply(flask.jsonify(message), 200 if success else 500) @blueprint.route('/hide_announcement', methods=["POST"]) @login.login_required_when_activated def hide_announcement(key): models.set_display_announcement(key, False) return util.get_rest_reply(flask.jsonify(""), 200) @blueprint.route('/start_copy_trading', methods=["POST"]) @login.login_required_when_activated def start_copy_trading(): try: copy_id = flask.request.get_json()["copy_id"] profile_id = flask.request.get_json()["profile_id"] if models.get_current_profile().profile_id != profile_id: models.select_profile(profile_id) response = f"{models.get_current_profile().name} profile selected" success, config_resp = models.update_copied_trading_id(copy_id) response = f"{response}, {config_resp}" return util.get_rest_reply(flask.jsonify(response)) if success else util.get_rest_reply(response, 500) except Exception as e: return util.get_rest_reply(f"Unexpected error : {e}", 500) @blueprint.route('/trading_strategies_tentacles_details', methods=["GET"]) @login.login_required_when_activated def trading_strategies_tentacles_details(backtestable_only): missing_tentacles = set() media_url = flask.url_for("tentacle_media", _external=True) evaluators = {} for evals in models.get_evaluator_detailed_config(media_url, missing_tentacles).values(): if isinstance(evals, dict): evaluators.update(evals) strategy_config = models.get_strategy_config( media_url, missing_tentacles, with_trading_modes=True, whitelist=None, backtestable_only=backtestable_only ) return flask.jsonify({ "trading_modes": strategy_config[models.TRADING_MODES_KEY], "strategies": strategy_config[models.STRATEGIES_KEY], "evaluators": evaluators, }) @blueprint.route('/tradingview_confirm_email_content', methods=["GET"]) @login.login_required_when_activated def tradingview_confirm_email_content(): try: return util.get_rest_reply( flask.jsonify(models.get_last_email_address_confirm_code_email_content()), 200 ) except octobot_commons.authentication.AuthenticationRequired: return util.get_rest_reply(flask.jsonify("authentication required"), 401) @blueprint.route('/trigger_wait_for_email_address_confirm_code_email', methods=["POST"]) @login.login_required_when_activated def trigger_wait_for_email_address_confirm_code_email(): try: models.wait_for_email_address_confirm_code_email() return util.get_rest_reply(flask.jsonify(""), 200) except octobot.community.errors.ExtensionRequiredError as err: return util.get_rest_reply(flask.jsonify(str(err)), 401) ================================================ FILE: Services/Interfaces/web_interface/api/dsl.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route("/dsl_keywords_docs", methods=['GET']) @login.login_required_when_activated def dsl_keywords_docs(): return flask.jsonify( [docs.to_json() for docs in models.get_dsl_keywords_docs()] ) ================================================ FILE: Services/Interfaces/web_interface/api/exchanges.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route("/are_compatible_accounts", methods=['POST']) @login.login_required_when_activated def are_compatible_accounts(): request_data = flask.request.get_json() return flask.jsonify(models.are_compatible_accounts(request_data)) @blueprint.route("/first_exchange_details") @login.login_required_when_activated def first_exchange_details(): exchange_name = flask.request.args.get('exchange_name', None) try: exchange_manager, exchange_name, exchange_id = models.get_first_exchange_data(exchange_name) return util.get_rest_reply( { "exchange_name": exchange_name, "exchange_id": exchange_id }, 200 ) except KeyError as e: return util.get_rest_reply(str(e), 404) ================================================ FILE: Services/Interfaces/web_interface/api/feedback.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route("/register_submitted_form", methods=['POST']) @login.login_required_when_activated def register_submitted_form(): request_data = flask.request.get_json() form_id = request_data["form_id"] user_id = request_data["user_id"] success, message = models.register_user_submitted_form(user_id, form_id) return util.get_rest_reply(flask.jsonify(message), 200 if success else 500) ================================================ FILE: Services/Interfaces/web_interface/api/metadata.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import json import flask_cors import cachetools import octobot.api as octobot_api import octobot.constants as constants import octobot_services.interfaces as interfaces import octobot_commons.constants import octobot_commons.timestamp_util as timestamp_util def register(blueprint): _LATEST_VERSION_CACHE = cachetools.TTLCache( maxsize=1, ttl=octobot_commons.constants.DAYS_TO_SECONDS ) @blueprint.route("/ping") @flask_cors.cross_origin() def ping(): start_time = interfaces.get_bot_api().get_start_time() return json.dumps( f"Running since " f"{timestamp_util.convert_timestamp_to_datetime(start_time, '%Y-%m-%d %H:%M:%S', local_timezone=True)}." ) @blueprint.route("/version") def version(): return json.dumps(f"{interfaces.AbstractInterface.project_name} {interfaces.AbstractInterface.project_version}") @blueprint.route("/upgrade_version") def upgrade_version(): async def fetch_upgrade_version(): updater = octobot_api.get_updater() return await updater.get_latest_version() if updater and await updater.should_be_updated() else None # avoid fetching upgrade version if already fetched in the last day try: version = _LATEST_VERSION_CACHE["version"] except KeyError: version = interfaces.run_in_bot_main_loop(fetch_upgrade_version(), timeout=5) _LATEST_VERSION_CACHE["version"] = version return json.dumps(version) @blueprint.route("/user_feedback") def user_feedback(): return json.dumps(constants.OCTOBOT_FEEDBACK_FORM_URL) @blueprint.route("/announcements") def announcements(): return "" # return json.dumps("external_resources_manager.get_external_resource( # service_constants.EXTERNAL_RESOURCE_PUBLIC_ANNOUNCEMENTS, # catch_exception=True)") ================================================ FILE: Services/Interfaces/web_interface/api/tentacles_packages.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route("/checkout_url", methods=['POST']) @login.login_required_when_activated def checkout_url(): request_data = flask.request.get_json() payment_method = request_data["paymentMethod"] redirect_url = request_data["redirectUrl"] success, url = models.get_checkout_url(payment_method, redirect_url) return util.get_rest_reply( { "url": url, }, 200 if success else 500 ) @blueprint.route("/has_open_source_package", methods=['POST']) @login.login_required_when_activated def has_open_source_package(): models.update_owned_packages() return util.get_rest_reply( { "has_open_source_package": models.has_open_source_package(), }, 200 ) ================================================ FILE: Services/Interfaces/web_interface/api/trading.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route("/orders", methods=['GET', 'POST']) @login.login_required_when_activated def orders(): if flask.request.method == 'GET': return flask.jsonify(models.get_all_orders_data()) elif flask.request.method == "POST": result = "" request_data = flask.request.get_json() action = flask.request.args.get("action") if action == "cancel_order": if interfaces_util.cancel_orders([request_data]): result = "Order cancelled" else: return util.get_rest_reply('Impossible to cancel order: order not found.', 500) elif action == "cancel_orders": removed_count = interfaces_util.cancel_orders(request_data) result = f"{removed_count} orders cancelled" return flask.jsonify(result) @blueprint.route("/trades", methods=['GET']) @login.login_required_when_activated def trades(): return flask.jsonify(models.get_all_trades_data()) @blueprint.route("/positions", methods=['GET', 'POST']) @login.login_required_when_activated def positions(): if flask.request.method == 'GET': return flask.jsonify(models.get_all_positions_data()) elif flask.request.method == "POST": result = "" request_data = flask.request.get_json() action = flask.request.args.get("action") if action == "close_position": if interfaces_util.close_positions([request_data]): result = "Position closed" else: return util.get_rest_reply('Impossible to close position: position already closed.', 500) return flask.jsonify(result) @blueprint.route("/refresh_portfolio", methods=['POST']) @login.login_required_when_activated def refresh_portfolio(): try: interfaces_util.trigger_portfolios_refresh() return flask.jsonify("Portfolio(s) refreshed") except RuntimeError: return util.get_rest_reply("No portfolio to refresh", 500) @blueprint.route("/currency_list", methods=['GET']) @login.login_required_when_activated def currency_list(): return flask.jsonify(models.get_all_symbols_list()) @blueprint.route("/historical_portfolio_value", methods=['GET']) @login.login_required_when_activated def historical_portfolio_value(): currency = flask.request.args.get("currency", "USDT") time_frame = flask.request.args.get("time_frame") from_timestamp = flask.request.args.get("from_timestamp") to_timestamp = flask.request.args.get("to_timestamp") exchange = flask.request.args.get("exchange") try: return flask.jsonify(models.get_portfolio_historical_values(currency, time_frame, from_timestamp, to_timestamp, exchange)) except KeyError: return util.get_rest_reply("No exchange portfolio", 404) @blueprint.route("/pnl_history", methods=['GET']) @login.login_required_when_activated def pnl_history(): exchange = flask.request.args.get("exchange") symbol = flask.request.args.get("symbol") quote = flask.request.args.get("quote") since = flask.request.args.get("since") scale = flask.request.args.get("scale", "") return flask.jsonify( models.get_pnl_history( exchange=exchange, quote=quote, symbol=symbol, since=since, scale=scale, ) ) @blueprint.route("/clear_orders_history", methods=['POST']) @login.login_required_when_activated def clear_orders_history(): return util.get_rest_reply(models.clear_exchanges_orders_history()) @blueprint.route("/clear_trades_history", methods=['POST']) @login.login_required_when_activated def clear_trades_history(): return util.get_rest_reply(models.clear_exchanges_trades_history()) @blueprint.route("/clear_portfolio_history", methods=['POST']) @login.login_required_when_activated def clear_portfolio_history(): return flask.jsonify(models.clear_exchanges_portfolio_history()) @blueprint.route("/clear_transactions_history", methods=['POST']) @login.login_required_when_activated def clear_transactions_history(): return flask.jsonify(models.clear_exchanges_transactions_history()) ================================================ FILE: Services/Interfaces/web_interface/api/user_commands.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_services.api as services_api import tentacles.Services.Interfaces.web_interface.login as login import octobot_services.interfaces.util as interfaces_util def register(blueprint): @blueprint.route("/user_command", methods=['POST']) @login.login_required_when_activated def user_command(): request_data = flask.request.get_json() interfaces_util.run_in_bot_main_loop( services_api.send_user_command( interfaces_util.get_bot_api().get_bot_id(), request_data["subject"], request_data["action"], request_data["data"] ) ) return flask.jsonify(request_data) ================================================ FILE: Services/Interfaces/web_interface/api/webhook.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.logging as logging _WEBHOOKS_CALLBACKS = [] def register_webhook(callback): _WEBHOOKS_CALLBACKS.append(callback) def has_webhook(callback): return callback in _WEBHOOKS_CALLBACKS def register(blueprint): @blueprint.route("/webhook/", methods=['POST']) def webhook(identifier): try: for callback in _WEBHOOKS_CALLBACKS: try: callback(identifier) except Exception as err: logging.get_logger(__name__).exception(err, True, f"Error when calling webhook: {err}") return '', 200 except KeyError: flask.abort(500) ================================================ FILE: Services/Interfaces/web_interface/constants.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. # utility URLs # top 250 sorted currencies (expects a page id at the end) CURRENCIES_LIST_URL = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250&page=" ALL_SYMBOLS_URL = "https://api.coingecko.com/api/v3/coins/list" # config keys CONFIG_WATCHED_SYMBOLS = "watched_symbols" # web interface keys GLOBAL_CONFIG_KEY = "global_config" EVALUATOR_CONFIG_KEY = "evaluator_config" TENTACLES_CONFIG_KEY = "tentacle_config" DEACTIVATE_OTHERS = "deactivate_others" TRADING_CONFIG_KEY = "trading_config" UPDATED_CONFIG_SEPARATOR = "_" ACTIVATION_KEY = "activation" TENTACLE_CLASS_NAME = "name" STARTUP_CONFIG_KEY = "startup_config" # backtesting BOT_TOOLS_BACKTESTING = "backtesting" BOT_TOOLS_BACKTESTING_SOURCE = "backtesting_source" BOT_PREPARING_BACKTESTING = "preparing_backtesting" # strategy optimizer BOT_TOOLS_STRATEGY_OPTIMIZER = "strategy_optimizer" # data collector BOT_TOOLS_DATA_COLLECTOR = "data_collector" PRODUCT_HUNT_ANNOUNCEMENT = "product_hunt_announcement" PRODUCT_HUNT_ANNOUNCEMENT_DAY = 1720594860 # Wednesday, July 10, 2024 7:01:00 AM UTC ================================================ FILE: Services/Interfaces/web_interface/controllers/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot.enums import tentacles.Services.Interfaces.web_interface.controllers.octobot_authentication import tentacles.Services.Interfaces.web_interface.controllers.community_authentication import tentacles.Services.Interfaces.web_interface.controllers.backtesting import tentacles.Services.Interfaces.web_interface.controllers.commands import tentacles.Services.Interfaces.web_interface.controllers.about import tentacles.Services.Interfaces.web_interface.controllers.community import tentacles.Services.Interfaces.web_interface.controllers.configuration import tentacles.Services.Interfaces.web_interface.controllers.tentacles_config import tentacles.Services.Interfaces.web_interface.controllers.dashboard import tentacles.Services.Interfaces.web_interface.controllers.errors import tentacles.Services.Interfaces.web_interface.controllers.octobot_help import tentacles.Services.Interfaces.web_interface.controllers.home import tentacles.Services.Interfaces.web_interface.controllers.interface_settings import tentacles.Services.Interfaces.web_interface.controllers.logs import tentacles.Services.Interfaces.web_interface.controllers.medias import tentacles.Services.Interfaces.web_interface.controllers.terms import tentacles.Services.Interfaces.web_interface.controllers.trading import tentacles.Services.Interfaces.web_interface.controllers.portfolio import tentacles.Services.Interfaces.web_interface.controllers.profiles import tentacles.Services.Interfaces.web_interface.controllers.automation import tentacles.Services.Interfaces.web_interface.controllers.reboot import tentacles.Services.Interfaces.web_interface.controllers.welcome import tentacles.Services.Interfaces.web_interface.controllers.robots import tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making import tentacles.Services.Interfaces.web_interface.controllers.dsl def register(blueprint, distribution: octobot.enums.OctoBotDistribution): if distribution is octobot.enums.OctoBotDistribution.DEFAULT: tentacles.Services.Interfaces.web_interface.controllers.community_authentication.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.backtesting.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.about.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.community.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.configuration.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.tentacles_config.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.dashboard.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.octobot_help.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.home.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.interface_settings.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.logs.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.trading.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.portfolio.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.profiles.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.automation.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.welcome.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.dsl.register(blueprint) elif distribution is octobot.enums.OctoBotDistribution.MARKET_MAKING: tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.register(blueprint) # common routes tentacles.Services.Interfaces.web_interface.controllers.octobot_authentication.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.robots.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.reboot.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.terms.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.errors.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.commands.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.medias.register(blueprint) __all__ = [ "register", ] ================================================ FILE: Services/Interfaces/web_interface/controllers/about.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot.constants as constants import octobot.disclaimer as disclaimer import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route("/about") @login.login_required_when_activated def about(): return flask.render_template('about.html', octobot_beta_program_form_url=constants.OCTOBOT_BETA_PROGRAM_FORM_URL, beta_env_enabled_in_config=models.get_beta_env_enabled_in_config(), metrics_enabled=models.get_metrics_enabled(), disclaimer=disclaimer.DISCLAIMER) ================================================ FILE: Services/Interfaces/web_interface/controllers/automation.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.logging as commons_logging import octobot_commons.authentication as authentication import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import octobot.automation as bot_automation import octobot.constants as constants def register(blueprint): @blueprint.route("/automations", methods=["POST", "GET"]) @login.login_required_when_activated def automations(): if not models.are_automations_enabled(): return flask.redirect(flask.url_for("home")) if flask.request.method == 'POST': action = flask.request.args.get("action") success = True response = "" tentacle_name = bot_automation.Automation.get_name() tentacle_class = bot_automation.Automation restart = False if action == "save": request_data = flask.request.get_json() success, response = models.update_tentacle_config( tentacle_name, request_data, tentacle_class=tentacle_class ) if action == "start": restart = True elif action == "factory_reset": success, response = models.reset_automation_config_to_default() restart = True if restart: models.restart_global_automations() if success: return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply(response, 500) display_intro = flask_util.BrowsingDataProvider.instance().get_and_unset_is_first_display( flask_util.BrowsingDataProvider.AUTOMATIONS ) all_events, all_conditions, all_actions = models.get_all_automation_steps() form_to_display = constants.AUTOMATION_FEEDBACK_FORM_ID try: user_id = models.get_user_account_id() display_feedback_form = models.has_at_least_one_running_automation() and not models.has_filled_form(form_to_display) except authentication.AuthenticationRequired: # no authenticated user: don't display form user_id = None display_feedback_form = False return flask.render_template( 'automations.html', profile_name=models.get_current_profile().name, events=all_events, conditions=all_conditions, actions=all_actions, display_intro=display_intro, user_id=user_id, form_to_display=form_to_display, display_feedback_form=display_feedback_form, ) @blueprint.route('/automations_edit_details') @login.login_required_when_activated def automations_edit_details(): if not models.are_automations_enabled(): return flask.redirect(flask.url_for("home")) try: return util.get_rest_reply( models.get_tentacle_config_and_edit_display( bot_automation.Automation.get_name(), tentacle_class=bot_automation.Automation ) ) except Exception as e: commons_logging.get_logger("automations_edit_details").exception(e) return util.get_rest_reply(str(e), 500) ================================================ FILE: Services/Interfaces/web_interface/controllers/backtesting.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import werkzeug import octobot_commons.time_frame_manager as time_frame_manager import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.errors as errors def register(blueprint): @blueprint.route("/backtesting") @blueprint.route('/backtesting', methods=['GET', 'POST']) @login.login_required_when_activated def backtesting(): if not models.is_backtesting_enabled(): return flask.redirect(flask.url_for("home")) if flask.request.method == 'POST': try: action_type = flask.request.args["action_type"] success = False reply = "Action failed" if action_type == "start_backtesting": data = flask.request.get_json() source = flask.request.args["source"] auto_stop = flask.request.args.get("auto_stop", False) run_on_common_part_only = flask.request.args.get("run_on_common_part_only", "true") == "true" reset_tentacle_config = flask.request.args.get("reset_tentacle_config", False) success, reply = models.start_backtesting_using_specific_files( data["files"], source, reset_tentacle_config, run_on_common_part_only, start_timestamp=data.get("start_timestamp", None), end_timestamp=data.get("end_timestamp", None), enable_logs=data.get("enable_logs", False), auto_stop=auto_stop, collector_start_callback=web_interface.send_data_collector_status, start_callback=web_interface.send_backtesting_status) elif action_type == "start_backtesting_with_current_bot_data": data = flask.request.get_json() source = flask.request.args["source"] auto_stop = flask.request.args.get("auto_stop", False) exchange_id = data.get("exchange_id", None) trading_type = data.get("exchange_type", None) profile_id = data.get("profile_id", None) name = data.get("name", None) reset_tentacle_config = flask.request.args.get("reset_tentacle_config", False) success, reply = models.start_backtesting_using_current_bot_data( data.get("data_source", models.CURRENT_BOT_DATA), exchange_id, source, reset_tentacle_config, start_timestamp=data.get("start_timestamp", None), end_timestamp=data.get("end_timestamp", None), trading_type=trading_type, profile_id=profile_id, enable_logs=data.get("enable_logs", False), auto_stop=auto_stop, name=name, collector_start_callback=web_interface.send_data_collector_status, start_callback=web_interface.send_backtesting_status ) elif action_type == "stop_backtesting": success, reply = models.stop_previous_backtesting() if success: return util.get_rest_reply(flask.jsonify(reply)) else: return util.get_rest_reply(reply, 500) except errors.MissingExchangeId: return util.get_rest_reply(errors.MissingExchangeId.EXPLANATION, 500) elif flask.request.method == 'GET': if flask.request.args: target = flask.request.args["update_type"] if target == "backtesting_report": source = flask.request.args["source"] backtesting_report = models.get_backtesting_report(source) return flask.jsonify(backtesting_report) else: return flask.render_template('backtesting.html', activated_trading_mode=models.get_config_activated_trading_mode(), data_files=models.get_data_files_with_description()) @blueprint.route("/backtesting_run_id") @login.login_required_when_activated def backtesting_run_id(): trading_mode = models.get_config_activated_trading_mode() run_id = models.get_latest_backtesting_run_id(trading_mode) return flask.jsonify(run_id) @blueprint.route("/data_collector") @blueprint.route('/data_collector', methods=['GET', 'POST']) @login.login_required_when_activated def data_collector(): if not models.is_backtesting_enabled(): return flask.redirect(flask.url_for("home")) if flask.request.method == 'POST': action_type = flask.request.args["action_type"] success = False reply = "Action failed" if action_type == "delete_data_file": file = flask.request.get_json() success, reply = models.get_delete_data_file(file) elif action_type == "start_collector": details = flask.request.get_json() success, reply = models.collect_data_file(details["exchange"], details["symbols"], details["time_frames"], details["startTimestamp"], details["endTimestamp"]) if success: web_interface.send_data_collector_status() elif action_type == "stop_collector": success, reply = models.stop_data_collector() elif action_type == "import_data_file": if flask.request.files: file = flask.request.files['file'] name = werkzeug.utils.secure_filename(flask.request.files['file'].filename) success, reply = models.save_data_file(name, file) alert = {"success": success, "message": reply} else: alert = {} current_exchange = models.get_current_exchange() # here return template to force page reload because of file upload via input form return flask.render_template('data_collector.html', data_files=models.get_data_files_with_description(), other_ccxt_exchanges=sorted(models.get_other_history_exchange_list()), full_candle_history_ccxt_exchanges=models.get_full_candle_history_exchange_list(), current_exchange=models.get_current_exchange(), full_symbol_list=sorted(models.get_symbol_list([current_exchange])), available_timeframes_list=[timeframe.value for timeframe in time_frame_manager.sort_time_frames( models.get_timeframes_list([current_exchange]))], alert=alert) if success: return util.get_rest_reply(flask.jsonify(reply)) else: return util.get_rest_reply(reply, 500) elif flask.request.method == 'GET': origin_page = None if flask.request.args: action_type_key = "action_type" if action_type_key in flask.request.args: target = flask.request.args[action_type_key] if target == "symbol_list": exchange = flask.request.args.get('exchange') return flask.jsonify(sorted(models.get_symbol_list([exchange]))) elif target == "available_timeframes_list": exchange = flask.request.args.get('exchange') return flask.jsonify([timeframe.value for timeframe in time_frame_manager.sort_time_frames( models.get_timeframes_list([exchange]))]) from_key = "from" if from_key in flask.request.args: origin_page = flask.request.args[from_key] current_exchange = models.get_current_exchange() return flask.render_template('data_collector.html', data_files=models.get_data_files_with_description(), other_ccxt_exchanges=sorted(models.get_other_history_exchange_list()), full_candle_history_ccxt_exchanges=models.get_full_candle_history_exchange_list(), current_exchange=models.get_current_exchange(), full_symbol_list=sorted(models.get_symbol_list([current_exchange])), available_timeframes_list=[timeframe.value for timeframe in time_frame_manager.sort_time_frames( models.get_timeframes_list([current_exchange]))], origin_page=origin_page, alert={}) ================================================ FILE: Services/Interfaces/web_interface/controllers/commands.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route('/commands/', methods=['GET', 'POST']) @login.login_required_when_activated def commands(cmd=None): if cmd == "restart": models.schedule_delayed_command(models.restart_bot, delay=0.1) return flask.jsonify("Success") elif cmd == "stop": models.schedule_delayed_command(models.stop_bot, delay=0.1) return flask.jsonify("Success") elif cmd == "update": models.schedule_delayed_command(models.update_bot, delay=0.1) return flask.jsonify("Update started") else: raise RuntimeError("Unknown command") ================================================ FILE: Services/Interfaces/web_interface/controllers/community.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.authentication as authentication import octobot.constants as constants import octobot_services.interfaces.util as interfaces_util import octobot.community.errors import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route("/community") @login.login_required_when_activated def community(): authenticator = authentication.Authenticator.instance() logged_in_email = None use_preview = not authenticator.can_authenticate() all_user_bots = [] try: models.wait_for_login_if_processing() logged_in_email = authenticator.get_logged_in_email() all_user_bots = models.get_all_user_bots() except authentication.AuthenticationError as err: # force logout and redirect to login flask.flash(f"Your session expired, please re-authenticate to your account.", "error") interfaces_util.run_in_bot_main_loop(authentication.Authenticator.instance().logout()) return flask.redirect('community_login') except (authentication.AuthenticationRequired, authentication.UnavailableError): # not authenticated pass except Exception as e: flask.flash(f"Error when contacting the community server: {e}", "error") if logged_in_email is None and not use_preview: return flask.redirect('community_login') strategies = models.get_cloud_strategies(authenticator) return flask.render_template( 'community.html', current_logged_in_email=logged_in_email, role=authenticator.user_account.supports.support_role, is_donor=bool(authenticator.user_account.supports.is_donor()), strategies=strategies, current_bots_stats=models.get_current_octobots_stats(), all_user_bots=all_user_bots, selected_user_bot=models.get_selected_user_bot(), can_logout=models.can_logout(), can_select_bot=models.can_select_bot(), has_owned_packages_to_install=models.has_owned_packages_to_install(), ) @blueprint.route("/community_metrics") @login.login_required_when_activated def community_metrics(): return flask.redirect("/") can_get_metrics = models.can_get_community_metrics() display_metrics = models.get_community_metrics_to_display() if can_get_metrics else None return flask.render_template('community_metrics.html', can_get_metrics=can_get_metrics, community_metrics=display_metrics ) @blueprint.route("/extensions") @login.login_required_when_activated def extensions(): refresh_packages = flask.request.args.get("refresh_packages") if flask.request.args else "false" loop = flask.request.args.get("loop") if flask.request.args else "false" authenticator = authentication.Authenticator.instance() logged_in_email = None try: models.wait_for_login_if_processing() logged_in_email = authenticator.get_logged_in_email() if refresh_packages.lower() == "true": models.update_owned_packages() except (authentication.AuthenticationRequired, authentication.UnavailableError, authentication.AuthenticationError): pass except Exception as e: flask.flash(f"Error when contacting the community server: {e}", "error") return flask.render_template( 'extensions.html', current_logged_in_email=logged_in_email, is_community_authenticated=logged_in_email is not None, price=constants.OCTOBOT_EXTENSION_PACKAGE_1_PRICE, auto_refresh_packages=refresh_packages and loop == "true", has_owned_packages_to_install=models.has_owned_packages_to_install(), ) @blueprint.route("/tradingview_email_config") @login.login_required_when_activated def tradingview_email_config(): models.wait_for_login_if_processing() return flask.render_template( 'tradingview_email_config.html', is_community_authenticated=authentication.Authenticator.instance().is_logged_in(), tradingview_email_address=models.get_tradingview_email_address(), ) ================================================ FILE: Services/Interfaces/web_interface/controllers/community_authentication.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import flask_wtf import wtforms.fields import octobot.community.errors as community_errors import octobot_commons.authentication as authentication import octobot_commons.logging as logging import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models VALIDATE_EMAIL_INFO = "Please validate your email from the confirm link we sent you and re-enter your credentials." def register(blueprint): @blueprint.route('/community_login', methods=['GET', 'POST']) @login.login_required_when_activated def community_login(): next_url = flask.request.args.get("next", None) after_login_action = flask.request.args.get("after_login_action", None) authenticator = authentication.Authenticator.instance() logged_in_email = None form = CommunityLoginForm(flask.request.form) if flask.request.form else CommunityLoginForm() try: logged_in_email = authenticator.get_logged_in_email() except authentication.AuthenticationRequired: pass except Exception as e: flask.flash(f"Error when contacting the community server: {e}", "error") if logged_in_email is None: if form.validate_on_submit(): try: interfaces_util.run_in_bot_main_loop( authenticator.login(form.email.data, form.password.data), log_exceptions=False ) logged_in_email = form.email.data if after_login_action == "sync_account": added_profiles = models.sync_community_account() if added_profiles: flask.flash(f"Downloaded {len(added_profiles)} profile{'s' if len(added_profiles) > 1 else ''} " f"from your OctoBot account.", "success") except community_errors.EmailValidationRequiredError: flask.flash(VALIDATE_EMAIL_INFO, "info") except authentication.FailedAuthentication as err: flask.flash(str(err), "error") except Exception as e: logging.get_logger("CommunityAuthentication").exception(e, False) flask.flash(f"Error during authentication: {e}", "error") if flask.request.method == 'POST' and next_url and authenticator.is_logged_in(): return flask.redirect(next_url) return flask.render_template('community_login.html', form=form, current_logged_in_email=logged_in_email, current_bots_stats=models.get_current_octobots_stats(), next_url=next_url or flask.url_for('community')) @blueprint.route('/community_register', methods=['GET', 'POST']) @login.login_required_when_activated def community_register(): if not models.can_logout(): return flask.redirect(flask.url_for('community')) next_url = flask.request.args.get("next", None) after_login_action = flask.request.args.get("after_login_action", None) authenticator = authentication.Authenticator.instance() form = CommunityLoginForm(flask.request.form) if flask.request.form else CommunityLoginForm() logged_in_email = None if form.validate_on_submit(): try: interfaces_util.run_in_bot_main_loop( authenticator.register(form.email.data, form.password.data), log_exceptions=False ) logged_in_email = form.email.data if after_login_action == "sync_account": added_profiles = models.sync_community_account() if added_profiles: flask.flash(f"Downloaded {len(added_profiles)} profile{'s' if len(added_profiles) > 1 else ''} " f"from your OctoBot account.", "success") # creation success: redirect to next_url if next_url: return flask.redirect(next_url) except community_errors.EmailValidationRequiredError: flask.flash(VALIDATE_EMAIL_INFO, "info") interfaces_util.run_in_bot_main_loop(authenticator.logout()) return flask.redirect(flask.url_for(f"community_login", **flask.request.args)) except authentication.AuthenticationError as err: flask.flash(str(err), "error") interfaces_util.run_in_bot_main_loop(authenticator.logout()) except Exception as e: logging.get_logger("CommunityAuthentication").exception(e, False) flask.flash(f"Unexpected error when creating account: {e}", "error") interfaces_util.run_in_bot_main_loop(authenticator.logout()) return flask.render_template('community_register.html', form=form, current_logged_in_email=logged_in_email, current_bots_stats=models.get_current_octobots_stats(), next_url=next_url or flask.url_for('community')) @blueprint.route("/community_logout") @login.login_required_when_activated def community_logout(): next_url = flask.request.args.get("next", flask.url_for("community_login")) if not models.can_logout(): return flask.redirect(flask.url_for('community')) interfaces_util.run_in_bot_main_loop(authentication.Authenticator.instance().logout()) return flask.redirect(next_url) class CommunityLoginForm(flask_wtf.FlaskForm): email = wtforms.fields.EmailField('Email', [wtforms.validators.InputRequired()]) password = wtforms.PasswordField('Password', [wtforms.validators.InputRequired()]) remember_me = wtforms.BooleanField('Remember me', default=True) ================================================ FILE: Services/Interfaces/web_interface/controllers/configuration.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import werkzeug import os from datetime import datetime import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.authentication as authentication import octobot_services.constants as services_constants import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import octobot_backtesting.api as backtesting_api import octobot_trading.api as trading_api import octobot_services.interfaces.util as interfaces_util def register(blueprint): @blueprint.route('/profile') @login.login_required_when_activated def profile(): selected_profile = flask.request.args.get("select", None) next_url = flask.request.args.get("next", None) if selected_profile is not None and selected_profile != models.get_current_profile().profile_id: models.select_profile(selected_profile) current_profile = models.get_current_profile() flask.flash( f"Selected the {current_profile.name} profile", "success" ) else: current_profile = models.get_current_profile() if next_url is not None: return flask.redirect(next_url) media_url = flask.url_for("tentacle_media", _external=True) display_config = interfaces_util.get_edited_config() missing_tentacles = set() profiles = models.get_profiles(commons_enums.ProfileType.LIVE) config_exchanges = display_config[commons_constants.CONFIG_EXCHANGES] enabled_exchange_types = models.get_enabled_exchange_types(config_exchanges) enabled_exchanges = trading_api.get_enabled_exchanges_names(display_config) display_intro = flask_util.BrowsingDataProvider.instance().get_and_unset_is_first_display( flask_util.BrowsingDataProvider.PROFILE ) exchange_symbols = sorted(models.get_symbol_list(enabled_exchanges or config_exchanges)) config_symbols = models.format_config_symbols(display_config) return flask.render_template( 'profile.html', current_profile=current_profile, profiles=profiles, profiles_tentacles_details=models.get_profiles_tentacles_details(profiles), display_intro=display_intro, config_exchanges=config_exchanges, enabled_exchange_types=enabled_exchange_types, config_trading=display_config[commons_constants.CONFIG_TRADING], config_trader=display_config[commons_constants.CONFIG_TRADER], config_trader_simulator=display_config[commons_constants.CONFIG_SIMULATOR], config_symbols=config_symbols, config_reference_market=display_config[commons_constants.CONFIG_TRADING][ commons_constants.CONFIG_TRADER_REFERENCE_MARKET], real_trader_activated=interfaces_util.has_real_and_or_simulated_traders()[0], symbol_list_by_type=models.get_all_symbols_list_by_symbol_type(exchange_symbols, config_symbols), full_symbol_list=models.get_all_symbols_list(), evaluator_config=models.get_evaluator_detailed_config(media_url, missing_tentacles), strategy_config=models.get_strategy_config(media_url, missing_tentacles), evaluator_startup_config=models.get_evaluators_tentacles_startup_activation(), trading_startup_config=models.get_trading_tentacles_startup_activation(), missing_tentacles=missing_tentacles, in_backtesting=backtesting_api.is_backtesting_enabled(display_config), other_tentacles_config=models.get_extra_tentacles_config_desc(media_url, missing_tentacles), config_tentacles_by_group=models.get_tentacles_activation_desc_by_group(media_url, missing_tentacles), exchanges_details=models.get_exchanges_details(config_exchanges), are_automations_enabled=models.are_automations_enabled(), automations_count=models.get_automations_count(), ) @blueprint.route('/profiles_management/', methods=["POST", "GET"]) @login.login_required_when_activated def profiles_management(action): if action == "update": data = flask.request.get_json() success, err = models.update_profile(flask.request.get_json()["id"], data) if not success: return util.get_rest_reply(flask.jsonify(str(err)), code=400) return util.get_rest_reply(flask.jsonify(data)) if action == "duplicate": profile_id = flask.request.args.get("profile_id") models.duplicate_profile(profile_id) flask.flash(f"New profile successfully created.", "success") return util.get_rest_reply(flask.jsonify("Profile created")) if action == "use_as_live": profile_id = flask.request.args.get("profile_id") models.convert_to_live_profile(profile_id) models.select_profile(profile_id) flask.flash(f"Profile successfully converted to live profile and selected.", "success") return flask.redirect(flask.url_for("profile")) if action == "remove": data = flask.request.get_json() to_remove_id = data["id"] removed_profile, err = models.remove_profile(to_remove_id) if err is not None: return util.get_rest_reply(flask.jsonify(str(err)), code=400) flask.flash(f"{removed_profile.name} profile removed.", "success") return util.get_rest_reply(flask.jsonify("Profile created")) next_url = flask.request.args.get("next", flask.url_for('profile')) if action == "import": file = flask.request.files['file'] name = werkzeug.utils.secure_filename(flask.request.files['file'].filename) try: new_profile = models.import_profile(file, name) flask.flash(f"{new_profile.name} profile successfully imported.", "success") except Exception as err: flask.flash(f"Error when importing profile: {err}.", "danger") return flask.redirect(next_url) if action == "download": url = flask.request.form.get('inputProfileLink') strategy_id = flask.request.json.get('strategy_id') name = flask.request.json.get('name') description = flask.request.json.get('description') profile_id = "" try: if url: new_profile = models.download_and_import_profile(url) else: if None in (strategy_id, name): raise RuntimeError("Both strategy_id and name are required to import a strategy") authenticator = authentication.Authenticator.instance() strategy = models.get_cloud_strategy(authenticator, strategy_id) new_profile = models.import_strategy_as_profile( authenticator, strategy, name, description ) profile_id = new_profile.profile_id message = f"{new_profile.name} profile successfully imported." success = True except FileNotFoundError: message = f"Invalid profile url {url}" success = False except Exception as err: message = f"Error when importing profile: {err}" success = False if flask.request.method == "POST": return util.get_rest_reply( flask.jsonify({"text": message, "profile_id": profile_id}), code=200 if success else 400 ) flask.flash(f"{message}", "success" if success else "danger") return flask.redirect(next_url) if action == "export": profile_id = flask.request.args.get("profile_id") temp_file = os.path.abspath("profile") file_path = models.export_profile(profile_id, temp_file) name = models.get_profile_name(profile_id) return flask_util.send_and_remove_file(file_path, f"{name}_{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip") @blueprint.route('/accounts') @login.login_required_when_activated def accounts(): display_config = interfaces_util.get_edited_config() # service lists service_list = models.get_services_list() notifiers_list = models.get_notifiers_list() config_exchanges = display_config[commons_constants.CONFIG_EXCHANGES] return flask.render_template('accounts.html', ccxt_tested_exchanges=models.get_tested_exchange_list(), ccxt_simulated_tested_exchanges=models.get_simulated_exchange_list(), ccxt_other_exchanges=sorted(models.get_other_exchange_list()), exchanges_details=models.get_exchanges_details(config_exchanges), config_exchanges=config_exchanges, config_notifications=display_config[ services_constants.CONFIG_CATEGORY_NOTIFICATION], config_services=display_config[services_constants.CONFIG_CATEGORY_SERVICES], services_list=service_list, notifiers_list=notifiers_list, ) @blueprint.route('/config', methods=['POST']) @login.login_required_when_activated def config(): next_url = flask.request.args.get("next", None) request_data = flask.request.get_json() success = True response = "" err_message = "" if request_data: # update trading config if required if constants.TRADING_CONFIG_KEY in request_data and request_data[constants.TRADING_CONFIG_KEY]: success = success and models.update_tentacles_activation_config( request_data[constants.TRADING_CONFIG_KEY]) else: request_data[constants.TRADING_CONFIG_KEY] = "" # update tentacles config if required if constants.TENTACLES_CONFIG_KEY in request_data and request_data[constants.TENTACLES_CONFIG_KEY]: success = success and models.update_tentacles_activation_config( request_data[constants.TENTACLES_CONFIG_KEY]) else: request_data[constants.TENTACLES_CONFIG_KEY] = "" # update evaluator config if required if constants.EVALUATOR_CONFIG_KEY in request_data and request_data[constants.EVALUATOR_CONFIG_KEY]: deactivate_others = False if constants.DEACTIVATE_OTHERS in request_data: deactivate_others = request_data[constants.DEACTIVATE_OTHERS] success = success and models.update_tentacles_activation_config( request_data[constants.EVALUATOR_CONFIG_KEY], deactivate_others) else: request_data[constants.EVALUATOR_CONFIG_KEY] = "" # remove elements from global config if any to remove removed_elements_key = "removed_elements" if removed_elements_key in request_data and request_data[removed_elements_key]: update_success, err_message = models.update_global_config(request_data[removed_elements_key], delete=True) success = success and update_success else: request_data[removed_elements_key] = "" # update global config if required if constants.GLOBAL_CONFIG_KEY in request_data and request_data[constants.GLOBAL_CONFIG_KEY]: success, err_message = models.update_global_config(request_data[constants.GLOBAL_CONFIG_KEY]) else: request_data[constants.GLOBAL_CONFIG_KEY] = "" response = { "evaluator_updated_config": request_data[constants.EVALUATOR_CONFIG_KEY], "trading_updated_config": request_data[constants.TRADING_CONFIG_KEY], "tentacle_updated_config": request_data[constants.TENTACLES_CONFIG_KEY], "global_updated_config": request_data[constants.GLOBAL_CONFIG_KEY], removed_elements_key: request_data[removed_elements_key] } if success: if request_data.get("restart_after_save", False): models.schedule_delayed_command(models.restart_bot) if next_url is not None: return flask.redirect(next_url) return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply(flask.jsonify(err_message), 500) @blueprint.route('/metrics_settings', methods=['POST']) @login.login_required_when_activated def metrics_settings(): return util.get_rest_reply(flask.jsonify(models.activate_metrics(flask.request.get_json()))) @blueprint.route('/beta_env_settings', methods=['POST']) @login.login_required_when_activated def beta_env_settings(): return util.get_rest_reply(flask.jsonify(models.activate_beta_env(flask.request.get_json()))) @blueprint.route('/config_actions', methods=['POST']) @login.login_required_when_activated def config_actions(): # action = flask.request.args.get("action") return util.get_rest_reply("No specified action.", code=500) ================================================ FILE: Services/Interfaces/web_interface/controllers/dashboard.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route( '/dashboard/currency_price_graph_update////') @login.login_required_when_activated def currency_price_graph_update(exchange_id, symbol, time_frame, mode="live"): in_backtesting = mode != "live" display_orders = flask.request.args.get("display_orders", "true") == "true" return flask.jsonify(models.get_currency_price_graph_update(exchange_id, models.get_value_from_dict_or_string(symbol), time_frame, backtesting=in_backtesting, ignore_orders=not display_orders)) @blueprint.route('/dashboard/first_symbol') @login.login_required_when_activated def first_symbol(): return flask.jsonify(models.get_first_symbol_data()) @blueprint.route('/dashboard/watched_symbol/') @login.login_required_when_activated def watched_symbol(symbol): return flask.jsonify(models.get_watched_symbol_data(symbol)) ================================================ FILE: Services/Interfaces/web_interface/controllers/distributions/__init__.py ================================================ ================================================ FILE: Services/Interfaces/web_interface/controllers/distributions/market_making/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Services.Interfaces.web_interface.controllers.portfolio import tentacles.Services.Interfaces.web_interface.controllers.logs import tentacles.Services.Interfaces.web_interface.controllers.dashboard import tentacles.Services.Interfaces.web_interface.controllers.tentacles_config import tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.dashboard import tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.configuration import tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.cloud def register(blueprint): tentacles.Services.Interfaces.web_interface.controllers.portfolio.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.logs.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.dashboard.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.tentacles_config.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.dashboard.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.configuration.register(blueprint) tentacles.Services.Interfaces.web_interface.controllers.distributions.market_making.cloud.register(blueprint) __all__ = [ "register", ] ================================================ FILE: Services/Interfaces/web_interface/controllers/distributions/market_making/cloud.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/cloud") @login.login_required_when_activated def cloud(): return flask.render_template( 'distributions/market_making/cloud.html', ) ================================================ FILE: Services/Interfaces/web_interface/controllers/distributions/market_making/configuration.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.logging as commons_logging import octobot_services.constants as services_constants import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import octobot_trading.api as trading_api def register(blueprint): @blueprint.route('/configuration') @login.login_required_when_activated def configuration(): display_intro = flask_util.BrowsingDataProvider.instance().get_and_unset_is_first_display( flask_util.BrowsingDataProvider.get_distribution_key( models.get_distribution(), flask_util.BrowsingDataProvider.CONFIGURATION, ) ) display_config = interfaces_util.get_edited_config() enabled_exchanges = trading_api.get_enabled_exchanges_names(display_config) config_symbols = models.get_enabled_trading_pairs() first_symbol_pair = next(iter(config_symbols)) if config_symbols else [] config_exchanges = models.get_json_exchange_config(display_config) trading_mode = models.get_config_activated_trading_mode() media_url = flask.url_for("tentacle_media", _external=True) tentacle_docs = "" trading_mode_name = trading_mode.get_name() if trading_mode else "Missing trading mode" if trading_mode: tentacle_docs = models.get_tentacle_documentation(trading_mode.get_name(), media_url) return flask.render_template( 'distributions/market_making/configuration.html', selected_exchange=enabled_exchanges[0] if enabled_exchanges else (config_exchanges[0][models.NAME] if config_exchanges else None), config_exchanges=config_exchanges, exchanges_schema=models.get_json_exchanges_schema(models.get_tested_exchange_list()), selected_pair=first_symbol_pair, trading_mode_name=trading_mode_name, tentacle_docs=tentacle_docs, simulated_portfolio=models.get_json_simulated_portfolio(display_config), portfolio_schema=models.JSON_PORTFOLIO_SCHEMA, trading_simulator_schema=models.JSON_TRADING_SIMULATOR_SCHEMA, config_trading_simulator=models.get_json_trading_simulator_config(display_config), display_intro=display_intro, ) @blueprint.route('/interfaces') @login.login_required_when_activated def interfaces(): display_config = interfaces_util.get_edited_config() # service lists service_list = models.get_market_making_services() services_config = { service: config for service, config in display_config[services_constants.CONFIG_CATEGORY_SERVICES].items() if service in service_list } notifiers_list = models.get_notifiers_list() return flask.render_template( 'distributions/market_making/interfaces.html', config_notifications=display_config[ services_constants.CONFIG_CATEGORY_NOTIFICATION], config_services=services_config, services_list=service_list, notifiers_list=notifiers_list, ) @blueprint.route('/interface_config', methods=['POST']) @login.login_required_when_activated def interface_config(): next_url = flask.request.args.get("next", None) request_data = flask.request.get_json() success = True response = "" err_message = "" if request_data: # remove elements from global config if any to remove removed_elements_key = "removed_elements" if removed_elements_key in request_data and request_data[removed_elements_key]: update_success, err_message = models.update_global_config(request_data[removed_elements_key], delete=True) success = success and update_success else: request_data[removed_elements_key] = "" # update global config if required if constants.GLOBAL_CONFIG_KEY in request_data and request_data[constants.GLOBAL_CONFIG_KEY]: success, err_message = models.update_global_config(request_data[constants.GLOBAL_CONFIG_KEY]) else: request_data[constants.GLOBAL_CONFIG_KEY] = "" response = { "global_updated_config": request_data[constants.GLOBAL_CONFIG_KEY], removed_elements_key: request_data[removed_elements_key] } if success: if request_data.get("restart_after_save", False): models.schedule_delayed_command(models.restart_bot) if next_url is not None: return flask.redirect(next_url) return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply(flask.jsonify(err_message), 500) @blueprint.route('/configuration', methods=['POST']) @login.login_required_when_activated def save_market_making_config(): request_data = flask.request.get_json() success = False response = "Restart to apply." err_message = None try: models.save_market_making_configuration( request_data["exchange"], request_data["tradingPair"], request_data["exchangesConfig"], request_data["tradingSimulatorConfig"], request_data["simulatedPortfolioConfig"], request_data["tradingModeName"], request_data["tradingModeConfig"], ) success = True except Exception as e: err_message = f"Failed to save market making configuration: {e.__class__.__name__}: {e}" commons_logging.get_logger("save_market_making_config").exception( e, True, f"{err_message} ({e.__class__.__name__})" ) if success: return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply(flask.jsonify(err_message), 500) ================================================ FILE: Services/Interfaces/web_interface/controllers/distributions/market_making/dashboard.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import time import flask import octobot_commons.authentication as authentication import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import tentacles.Services.Interfaces.web_interface.constants as web_constants import octobot.constants as constants import octobot_commons.constants import octobot_commons.enums def register(blueprint): @blueprint.route("/") @blueprint.route("/home") @login.login_required_when_activated def home(): if flask.request.args.get("reset_tutorials", "False").lower() == "true": flask_util.BrowsingDataProvider.instance().set_first_displays(True) if models.accepted_terms(): display_intro = flask_util.BrowsingDataProvider.instance().get_and_unset_is_first_display( flask_util.BrowsingDataProvider.get_distribution_key( models.get_distribution(), flask_util.BrowsingDataProvider.HOME, ) ) all_time_frames = models.get_all_watched_time_frames() display_time_frame = models.get_display_timeframe() display_orders = models.get_display_orders() sandbox_exchanges = models.get_sandbox_exchanges() past_launch_time = ( web_constants.PRODUCT_HUNT_ANNOUNCEMENT_DAY + ( octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames.ONE_DAY] * octobot_commons.constants.MINUTE_TO_SECONDS ) ) is_launching = ( web_constants.PRODUCT_HUNT_ANNOUNCEMENT_DAY <= time.time() <= past_launch_time ) display_ph_launch = ( models.get_display_announcement(web_constants.PRODUCT_HUNT_ANNOUNCEMENT) or is_launching ) and not time.time() > past_launch_time return flask.render_template( 'distributions/market_making/dashboard.html', display_intro=display_intro, reference_unit=interfaces_util.get_reference_market(), display_time_frame=display_time_frame, display_orders=display_orders, all_time_frames=all_time_frames, sandbox_exchanges=sandbox_exchanges, display_ph_launch=display_ph_launch, is_launching=is_launching, ) else: return flask.redirect(flask.url_for("terms")) @blueprint.route("/welcome") def welcome(): # used in terms page return flask.redirect(flask.url_for("home")) ================================================ FILE: Services/Interfaces/web_interface/controllers/dsl.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/dsl_help", methods=["GET"]) @login.login_required_when_activated def dsl_help(): return flask.render_template('dsl_help.html') ================================================ FILE: Services/Interfaces/web_interface/controllers/errors.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.logging as bot_logging import tentacles.Services.Interfaces.web_interface.util as util APP_JSON_CONTENT_TYPE = "application/json" def register(blueprint): @blueprint.errorhandler(404) def not_found(_): if flask.request.content_type == APP_JSON_CONTENT_TYPE: return util.get_rest_reply("We are sorry, but this doesn't exist", 404) return flask.render_template("404.html"), 404 @blueprint.errorhandler(500) def internal_error(error): bot_logging.get_logger("WebInterfaceErrorHandler").exception(error.original_exception, True, f"Error when displaying page: " f"{error.original_exception}") if flask.request.content_type == APP_JSON_CONTENT_TYPE: return util.get_rest_reply(f"We are sorry, but an unexpected error occurred: {error.original_exception} " f"({error.original_exception.__class__.__name__})", 500) return flask.render_template("500.html", error=error.original_exception), 500 ================================================ FILE: Services/Interfaces/web_interface/controllers/home.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import time import flask import octobot_commons.authentication as authentication import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import tentacles.Services.Interfaces.web_interface.constants as web_constants import octobot.constants as constants import octobot_commons.constants import octobot_commons.enums def register(blueprint): @blueprint.route("/") @blueprint.route("/home") @login.login_required_when_activated def home(): if flask.request.args.get("reset_tutorials", "False") == "True": flask_util.BrowsingDataProvider.instance().set_first_displays(True) if models.accepted_terms(): trading_delay_info = flask.request.args.get("trading_delay_info", 'false').lower() == "true" in_backtesting = models.get_in_backtesting_mode() display_intro = flask_util.BrowsingDataProvider.instance().get_and_unset_is_first_display( flask_util.BrowsingDataProvider.HOME ) form_to_display = constants.WELCOME_FEEDBACK_FORM_ID pnl_symbols = models.get_pnl_history_symbols() all_time_frames = models.get_all_watched_time_frames() display_time_frame = models.get_display_timeframe() display_orders = models.get_display_orders() sandbox_exchanges = models.get_sandbox_exchanges() try: user_id = models.get_user_account_id() display_feedback_form = form_to_display and not models.has_filled_form(form_to_display) except authentication.AuthenticationRequired: # no authenticated user: don't display form user_id = None display_feedback_form = False past_launch_time = ( web_constants.PRODUCT_HUNT_ANNOUNCEMENT_DAY + ( octobot_commons.enums.TimeFramesMinutes[octobot_commons.enums.TimeFrames.ONE_DAY] * octobot_commons.constants.MINUTE_TO_SECONDS ) ) is_launching = ( web_constants.PRODUCT_HUNT_ANNOUNCEMENT_DAY <= time.time() <= past_launch_time ) display_ph_launch = ( models.get_display_announcement(web_constants.PRODUCT_HUNT_ANNOUNCEMENT) or is_launching ) and not time.time() > past_launch_time return flask.render_template( 'index.html', has_pnl_history=bool(pnl_symbols), watched_symbols=models.get_watched_symbols(), backtesting_mode=in_backtesting, display_intro=display_intro, display_trading_delay_info=trading_delay_info, selected_profile=models.get_current_profile().name, reference_unit=interfaces_util.get_reference_market(), display_time_frame=display_time_frame, display_orders=display_orders, all_time_frames=all_time_frames, user_id=user_id, form_to_display=form_to_display, display_feedback_form=display_feedback_form, sandbox_exchanges=sandbox_exchanges, display_ph_launch=display_ph_launch, is_launching=is_launching, latest_release_url=f"{octobot_commons.constants.GITHUB_BASE_URL}/" f"{octobot_commons.constants.GITHUB_ORGANISATION}/" f"{constants.PROJECT_NAME}/releases/latest", ) else: return flask.redirect(flask.url_for("terms")) ================================================ FILE: Services/Interfaces/web_interface/controllers/interface_settings.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route("/watched_symbols") @blueprint.route('/watched_symbols', methods=['POST']) @login.login_required_when_activated def watched_symbols(): if flask.request.method == 'POST': result = False request_data = flask.request.get_json() symbol = request_data["symbol"] action = request_data["action"] action_desc = "added to" if action == 'add': result = models.add_watched_symbol(symbol) elif action == 'remove': result = models.remove_watched_symbol(symbol) action_desc = "removed from" if result: return util.get_rest_reply(flask.jsonify(f"{symbol} {action_desc} watched markets")) else: return util.get_rest_reply(f'Error: {symbol} not {action_desc} watched markets.', 500) ================================================ FILE: Services/Interfaces/web_interface/controllers/logs.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import os import octobot_commons.constants as commons_constants import octobot_commons.logging as logging import octobot_tentacles_manager.constants as tentacles_manager_constants import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.flask_util as flask_util def register(blueprint): @blueprint.route("/logs") @login.login_required_when_activated def logs(): web_interface.flush_errors_count() return flask.render_template("logs.html", logs=web_interface.get_logs(), notifications=web_interface.get_notifications_history()) @blueprint.route("/export_logs") @login.login_required_when_activated def export_logs(): # use user folder as the bot always has the right to use it, on failure, try in tentacles folder for candidate_path in (commons_constants.USER_FOLDER, tentacles_manager_constants.TENTACLES_PATH): temp_file = os.path.abspath(os.path.join(os.getcwd(), candidate_path, "exported_logs")) temp_file_with_ext = f"{temp_file}.{models.LOG_EXPORT_FORMAT}" try: if os.path.isdir(temp_file_with_ext): raise RuntimeError(f"To be able to export logs, please remove or rename the {temp_file_with_ext} directory") elif os.path.isfile(temp_file_with_ext): os.remove(temp_file_with_ext) file_path = models.export_logs(temp_file) return flask_util.send_and_remove_file(file_path, "logs_export.zip") except Exception as err: logging.get_logger("export_logs").exception(err, True, f"Unexpected error when exporting logs: {err}") error = err flask.flash(f"Error when exporting logs: {error}.", "danger") return flask.redirect(flask.url_for("logs")) ================================================ FILE: Services/Interfaces/web_interface/controllers/medias.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import os import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def _send_file(base_dir, file_path): base_path, file_name = os.path.split(file_path) return flask.send_from_directory(os.path.join(base_dir, base_path), file_name) def register(blueprint): @blueprint.route('/tentacle_media') @blueprint.route('/tentacle_media/') @login.login_required_when_activated def tentacle_media(path=None): # images if models.is_valid_tentacle_image_path(path): # reference point is the web interface directory: use OctoBot root folder as a reference return _send_file("../../../..", path) @blueprint.route('/profile_media/') @login.login_required_when_activated def profile_media(path): # images if models.is_valid_profile_image_path(path): # reference point is the web interface directory: use OctoBot root folder as a reference return _send_file("../../../..", path) @blueprint.route('/exchange_logo/') @login.login_required_when_activated def exchange_logo(name): return flask.jsonify(models.get_exchange_logo(name)) @blueprint.route('/audio_media/') @login.login_required_when_activated def audio_media(name): if models.is_valid_audio_path(name): # reference point is the web interface directory: use OctoBot root folder as a reference return _send_file("static/audio", name) @blueprint.route('/currency_logos', methods=['POST']) @login.login_required_when_activated def cryptocurrency_logos(): request_data = flask.request.get_json() return flask.jsonify(models.get_currency_logo_urls(request_data["currency_ids"])) ================================================ FILE: Services/Interfaces/web_interface/controllers/octobot_authentication.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import flask_login import flask_wtf import wtforms import octobot_commons.logging as bot_logging import octobot_commons.authentication as authentication import tentacles.Services.Interfaces.web_interface.login as web_login import tentacles.Services.Interfaces.web_interface.security as security logger = bot_logging.get_logger("ServerInstance Controller") def register(blueprint): @blueprint.route('/login', methods=['GET', 'POST']) def login(): # use default constructor to apply default values when no form in request form = LoginForm(flask.request.form) if flask.request.form else LoginForm() if form.validate_on_submit(): if blueprint.login_manager.is_valid_password( flask.request.remote_addr, form.password.data, form ): blueprint.login_manager.login_user(form.remember_me.data) web_login.reset_attempts(flask.request.remote_addr) return _get_next_url_or_home_redirect() if web_login.register_attempt(flask.request.remote_addr): if not form.password.errors: form.password.errors.append('Invalid password') logger.warning(f"Invalid login attempt from : {flask.request.remote_addr}") else: form.password.errors.append('Too many attempts. Please restart your OctoBot to be able to login.') return flask.render_template( 'login.html', form=form, is_remote_login=authentication.Authenticator.instance().must_be_authenticated_through_authenticator() ) @blueprint.route("/logout") @flask_login.login_required def logout(): flask_login.logout_user() return _get_next_url_or_home_redirect() def _get_next_url_or_home_redirect(): next_url = flask.request.args.get('next') if not security.is_safe_url(next_url): return flask.abort(400) return flask.redirect(next_url or flask.url_for('home')) class LoginForm(flask_wtf.FlaskForm): password = wtforms.PasswordField('Password') remember_me = wtforms.BooleanField('Remember me', default=True) ================================================ FILE: Services/Interfaces/web_interface/controllers/octobot_help.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/octobot_help") @login.login_required_when_activated def octobot_help(): return flask.render_template('octobot_help.html') ================================================ FILE: Services/Interfaces/web_interface/controllers/portfolio.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_services.interfaces.util as interfaces_util import octobot_commons.constants as commons_constants import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route("/portfolio") @login.login_required_when_activated def portfolio(): has_real_trader, has_simulated_trader = interfaces_util.has_real_and_or_simulated_traders() displayed_portfolio = models.get_exchange_holdings_per_symbol() symbols_values = models.get_symbols_values(displayed_portfolio.keys(), has_real_trader, has_simulated_trader) \ if displayed_portfolio else {} _, _, portfolio_real_current_value, portfolio_simulated_current_value = \ interfaces_util.get_portfolio_current_value() displayed_portfolio_value = portfolio_real_current_value if has_real_trader else portfolio_simulated_current_value reference_market = interfaces_util.get_reference_market() initializing_currencies_prices_set = models.get_initializing_currencies_prices_set( commons_constants.HOURS_TO_SECONDS ) return flask.render_template('portfolio.html', has_real_trader=has_real_trader, has_simulated_trader=has_simulated_trader, displayed_portfolio=displayed_portfolio, symbols_values=symbols_values, displayed_portfolio_value=round(displayed_portfolio_value, 8), reference_unit=reference_market, initializing_currencies_prices=initializing_currencies_prices_set, ) ================================================ FILE: Services/Interfaces/web_interface/controllers/profiles.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.authentication as authentication import octobot_commons.constants as commons_constants import octobot_commons.logging as commons_logging import octobot_commons.enums as commons_enums import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.controllers.community_authentication as community_authentication import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import octobot_services.interfaces.util as interfaces_util import octobot_trading.api as trading_api def register(blueprint): @blueprint.route("/profiles_selector") @login.login_required_when_activated def profiles_selector(): reboot = flask.request.args.get("reboot", "false").lower() == "true" onboarding = flask.request.args.get("onboarding", 'false').lower() == "true" use_cloud = flask.request.args.get("use_cloud", 'false').lower() == "true" models.wait_for_login_if_processing() # skip profile selector when forced profile if onboarding and models.get_forced_profile() is not None: return flask.redirect(flask.url_for("trading_type_selector", reboot=reboot, onboarding=onboarding)) profiles = models.get_profiles(commons_enums.ProfileType.LIVE) current_profile = models.get_current_profile() display_config = interfaces_util.get_edited_config() config_exchanges = display_config[commons_constants.CONFIG_EXCHANGES] enabled_exchanges = trading_api.get_enabled_exchanges_names(display_config) media_url = flask.url_for("tentacle_media", _external=True) missing_tentacles = set() logged_in_email = None form = community_authentication.CommunityLoginForm(flask.request.form) \ if flask.request.form else community_authentication.CommunityLoginForm() authenticator = authentication.Authenticator.instance() try: logged_in_email = authenticator.get_logged_in_email() except (authentication.AuthenticationRequired, authentication.UnavailableError, authentication.AuthenticationError): pass cloud_strategies = [] try: cloud_strategies = models.get_cloud_strategies(authenticator) except Exception as err: # don't crash the page if this request fails commons_logging.get_logger("profile_selector").exception( err, True, f"Error when fetching cloud strategies: {err}" ) display_intro = flask_util.BrowsingDataProvider.instance().get_and_unset_is_first_display( flask_util.BrowsingDataProvider.PROFILE_SELECTOR ) return flask.render_template( 'profiles_selector.html', show_nab_bar=not onboarding, onboarding=onboarding, read_only=True, use_cloud=use_cloud, reboot=reboot, display_intro=display_intro, current_logged_in_email=logged_in_email, selected_user_bot=models.get_selected_user_bot(), can_logout=models.can_logout(), form=form, current_profile=current_profile, profiles=profiles.values(), profiles_tentacles_details=models.get_profiles_tentacles_details(profiles), cloud_strategies=cloud_strategies, evaluator_config=models.get_evaluator_detailed_config(media_url, missing_tentacles), strategy_config=models.get_strategy_config(media_url, missing_tentacles), config_exchanges=config_exchanges, symbol_list=sorted(models.get_symbol_list(enabled_exchanges or config_exchanges)), ) ================================================ FILE: Services/Interfaces/web_interface/controllers/reboot.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models def register(blueprint): @blueprint.route("/wait_reboot") @login.login_required_when_activated def wait_reboot(): trading_delay_info = flask.request.args.get("trading_delay_info", 'false').lower() == "true" next_url = flask.request.args.get("next", flask.url_for("home", trading_delay_info=trading_delay_info)) reboot = flask.request.args.get("reboot", "false").lower() == "true" onboarding = flask.request.args.get("onboarding", 'false').lower() == "true" if reboot: return_val = flask.render_template( 'wait_reboot.html', show_nab_bar=not onboarding, onboarding=onboarding, next_url=next_url, current_profile_name=models.get_current_profile().name, ) if not models.is_rebooting(): reboot_delay = 2 # schedule reboot now that the page render has been computed models.restart_bot(delay=reboot_delay) else: return_val = flask.redirect(next_url) return return_val ================================================ FILE: Services/Interfaces/web_interface/controllers/robots.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask def register(blueprint): @blueprint.route("/robots.txt") def robots(): return flask.render_template("robots.txt") ================================================ FILE: Services/Interfaces/web_interface/controllers/tentacles_config.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot_commons.logging as commons_logging import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.util as util def register(blueprint): @blueprint.route('/config_tentacle', methods=['GET', 'POST']) @login.login_required_when_activated def config_tentacle(): if flask.request.method == 'POST': tentacle_name = flask.request.args.get("name") action = flask.request.args.get("action") profile_id = flask.request.args.get("profile_id") restart = flask.request.args.get("restart", "false") == "true" tentacles_setup_config = models.get_tentacles_setup_config_from_profile_id(profile_id) if profile_id else None success = True response = "" reload_config = False if action == "update": request_data = flask.request.get_json() success, response = models.update_tentacle_config( tentacle_name, request_data, tentacles_setup_config=tentacles_setup_config ) reload_config = True elif action == "factory_reset": success, response = models.reset_config_to_default( tentacle_name, tentacles_setup_config=tentacles_setup_config ) reload_config = True if flask.request.args.get("reload"): try: models.reload_scripts() except Exception as e: success = False response = str(e) if reload_config and success: try: models.reload_tentacle_config(tentacle_name) except Exception as e: success = False response = f"Error when reloading configuration {e}" if success: if restart: models.schedule_delayed_command(models.restart_bot) return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply(response, 500) else: if flask.request.args: tentacle_name = flask.request.args.get("name") missing_tentacles = set() media_url = flask.url_for("tentacle_media", _external=True) tentacle_class, tentacle_type, tentacle_desc = models.get_tentacle_from_string(tentacle_name, media_url) is_strategy = tentacle_type == "strategy" is_trading_mode = tentacle_type == "trading mode" evaluator_config = None strategy_config = None requirements = tentacle_desc.get(models.REQUIREMENTS_KEY, []) wildcard_requirements = requirements == ["*"] if is_strategy and wildcard_requirements: evaluator_config = models.get_evaluator_detailed_config( media_url, missing_tentacles, single_strategy=tentacle_name ) elif is_trading_mode and len(requirements) > 1: strategy_config = models.get_strategy_config( media_url, missing_tentacles, with_trading_modes=False, whitelist=None if wildcard_requirements else requirements ) evaluator_startup_config = models.get_evaluators_tentacles_startup_activation() \ if evaluator_config or strategy_config else None tentacle_commands = models.get_tentacle_user_commands(tentacle_class) is_trading_strategy_configuration = models.is_trading_strategy_configuration(tentacle_type) return flask.render_template( 'config_tentacle.html', name=tentacle_name, tentacle_type=tentacle_type, tentacle_class=tentacle_class, tentacle_desc=tentacle_desc, evaluator_startup_config=evaluator_startup_config, strategy_config=strategy_config, evaluator_config=evaluator_config, is_trading_strategy_configuration=is_trading_strategy_configuration, activated_trading_mode=models.get_config_activated_trading_mode() if is_trading_strategy_configuration else None, data_files=models.get_data_files_with_description() if is_trading_strategy_configuration else None, missing_tentacles=missing_tentacles, user_commands=tentacle_commands, current_profile=models.get_current_profile() ) else: return flask.render_template('config_tentacle.html') @blueprint.route('/config_tentacle_edit_details/') @login.login_required_when_activated def config_tentacle_edit_details(tentacle): try: profile_id = flask.request.args.get("profile", None) return util.get_rest_reply( models.get_tentacle_config_and_edit_display(tentacle, profile_id=profile_id) ) except Exception as e: commons_logging.get_logger("configuration").exception(e) return util.get_rest_reply(str(e), 500) @blueprint.route('/config_tentacles', methods=['POST']) @login.login_required_when_activated def config_tentacles(): action = flask.request.args.get("action") profile_id = flask.request.args.get("profile_id") tentacles_setup_config = models.get_tentacles_setup_config_from_profile_id(profile_id) if profile_id else None success = True response = "" if action == "update": request_data = flask.request.get_json() responses = [] for tentacle, tentacle_config in request_data.items(): update_success, update_response = models.update_tentacle_config( tentacle, tentacle_config, tentacles_setup_config=tentacles_setup_config ) success = update_success and success responses.append(update_response) response = ", ".join(responses) if success and flask.request.args.get("reload"): try: models.reload_activated_tentacles_config() except Exception as e: success = False response = str(e) if success: return util.get_rest_reply(flask.jsonify(response)) else: return util.get_rest_reply(response, 500) ================================================ FILE: Services/Interfaces/web_interface/controllers/terms.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import octobot.disclaimer as disclaimer import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.flask_util as flask_util def register(blueprint): @blueprint.route("/terms") @login.login_required_when_activated def terms(): return flask.render_template("terms.html", disclaimer=disclaimer.DISCLAIMER, accepted_terms=models.accepted_terms()) @blueprint.route("/accept_terms") @login.login_required_when_activated def accept_terms(): next_url = flask.request.args.get("next", None) if flask.request.args.get("accept_terms", None) == "True": models.accept_terms(True) flask_util.BrowsingDataProvider.instance().set_first_displays(True) return flask.redirect(next_url or flask.url_for("home")) return flask.redirect(flask.url_for("terms")) ================================================ FILE: Services/Interfaces/web_interface/controllers/trading.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import datetime import flask import octobot_services.interfaces.util as interfaces_util import octobot_commons.constants as commons_constants import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.models as models import octobot_trading.api as trading_api def register(blueprint): @blueprint.route("/symbol_market_status") @blueprint.route('/symbol_market_status', methods=['GET', 'POST']) @login.login_required_when_activated def symbol_market_status(): exchange_id = flask.request.args["exchange_id"] symbol = flask.request.args["symbol"] symbol_time_frames, exchange = models.get_exchange_watched_time_frames(exchange_id) time_frames = list(symbol_time_frames) time_frames.reverse() symbol_evaluation = models.get_evaluation(symbol, exchange, exchange_id) return flask.render_template('symbol_market_status.html', symbol=symbol, exchange=exchange, exchange_id=exchange_id, symbol_evaluation=symbol_evaluation, time_frames=time_frames, backtesting_mode=models.get_in_backtesting_mode()) @blueprint.route("/trading") @login.login_required_when_activated def trading(): displayed_portfolio = models.get_exchange_holdings_per_symbol() has_real_trader, has_simulated_trader = interfaces_util.has_real_and_or_simulated_traders() symbols_values = models.get_symbols_values(displayed_portfolio.keys(), has_real_trader, has_simulated_trader) \ if displayed_portfolio else {} has_real_trader, _ = interfaces_util.has_real_and_or_simulated_traders() exchanges_load = models.get_exchanges_load() pnl_symbols = models.get_pnl_history_symbols() return flask.render_template( 'trading.html', might_have_positions=models.has_futures_exchange(), watched_symbols=models.get_watched_symbols(), pairs_with_status=interfaces_util.get_currencies_with_status(), displayed_portfolio=displayed_portfolio, symbols_values=symbols_values, has_real_trader=has_real_trader, exchanges_load=exchanges_load, is_community_feed_connected=models.is_community_feed_connected(), last_signal_time=models.get_last_signal_time(), followed_strategy_url=models.get_followed_strategy_url(), reference_market=interfaces_util.get_reference_market(), pnl_symbols=pnl_symbols, has_pnl_history=bool(pnl_symbols), ) @blueprint.route("/trading_type_selector") @login.login_required_when_activated def trading_type_selector(): onboarding = flask.request.args.get("onboarding", 'false').lower() == "true" display_config = interfaces_util.get_edited_config() config_exchanges = display_config[commons_constants.CONFIG_EXCHANGES] enabled_exchanges = trading_api.get_enabled_exchanges_names(display_config) or [models.get_default_exchange()] current_profile = models.get_current_profile() return_val = flask.render_template( 'trading_type_selector.html', show_nab_bar=not onboarding, onboarding=onboarding, current_profile_name=current_profile.name, config_exchanges=config_exchanges, enabled_exchanges=enabled_exchanges, exchanges_details=models.get_exchanges_details(config_exchanges), ccxt_tested_exchanges=models.get_tested_exchange_list(), ccxt_simulated_tested_exchanges=models.get_simulated_exchange_list(), ccxt_other_exchanges=sorted(models.get_other_exchange_list()), simulated_portfolio=models.get_json_simulated_portfolio(display_config), portfolio_schema=models.JSON_PORTFOLIO_SCHEMA, real_trader_activated=models.is_real_trading(current_profile), ) return return_val @blueprint.context_processor def utility_processor(): def convert_timestamp(str_time): return datetime.datetime.fromtimestamp(str_time).strftime('%Y-%m-%d %H:%M:%S') def convert_type(order_type): return order_type.name return dict(convert_timestamp=convert_timestamp, convert_type=convert_type) ================================================ FILE: Services/Interfaces/web_interface/controllers/welcome.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import tentacles.Services.Interfaces.web_interface.login as login def register(blueprint): @blueprint.route("/welcome") @login.login_required_when_activated def welcome(): return flask.render_template("welcome.html") ================================================ FILE: Services/Interfaces/web_interface/enums.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import enum class PriceStrings(enum.Enum): STR_PRICE_TIME = "time" STR_PRICE_CLOSE = "close" STR_PRICE_OPEN = "open" STR_PRICE_HIGH = "high" STR_PRICE_LOW = "low" STR_PRICE_VOL = "vol" class TabsLocation(enum.Enum): START = "start" END = "end" class ColorModes(enum.Enum): LIGHT = "light" DARK = "dark" DEFAULT = "light" ================================================ FILE: Services/Interfaces/web_interface/errors.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. class MissingExchangeId(Exception): EXPLANATION = "Invalid exchange id, this might be due to a recent restart. " \ "Refresh this page if the error persists" """ Raised when an exchange id is not existing in the current bot """ pass ================================================ FILE: Services/Interfaces/web_interface/flask_util/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Services.Interfaces.web_interface.flask_util import content_types_management from tentacles.Services.Interfaces.web_interface.flask_util.content_types_management import ( init_content_types, ) from tentacles.Services.Interfaces.web_interface.flask_util import context_processor from tentacles.Services.Interfaces.web_interface.flask_util.context_processor import ( register_context_processor, ) from tentacles.Services.Interfaces.web_interface.flask_util import file_services from tentacles.Services.Interfaces.web_interface.flask_util.file_services import ( send_and_remove_file, ) from tentacles.Services.Interfaces.web_interface.flask_util import template_filters from tentacles.Services.Interfaces.web_interface.flask_util.template_filters import ( register_template_filters, ) from tentacles.Services.Interfaces.web_interface.flask_util import json_provider from tentacles.Services.Interfaces.web_interface.flask_util.json_provider import ( FloatDecimalJSONProvider, ) from tentacles.Services.Interfaces.web_interface.flask_util import cors from tentacles.Services.Interfaces.web_interface.flask_util.cors import ( get_user_defined_cors_allowed_origins, ) from tentacles.Services.Interfaces.web_interface.flask_util import browsing_data_provider from tentacles.Services.Interfaces.web_interface.flask_util.browsing_data_provider import ( BrowsingDataProvider, ) __all__ = [ "init_content_types", "register_context_processor", "send_and_remove_file", "register_template_filters", "FloatDecimalJSONProvider", "get_user_defined_cors_allowed_origins", "BrowsingDataProvider", ] ================================================ FILE: Services/Interfaces/web_interface/flask_util/browsing_data_provider.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import json import secrets import time import octobot_commons.singleton as singleton import octobot_commons.logging as logging import octobot_commons.constants as constants import octobot_commons.json_util as json_util import octobot_commons.configuration as commons_configuration import octobot_commons.authentication as commons_authentication from octobot_services.interfaces import run_in_bot_main_loop import octobot.enums _PREFIX_BY_DISTRIBUTION = { octobot.enums.OctoBotDistribution.DEFAULT.value: "", octobot.enums.OctoBotDistribution.MARKET_MAKING.value: "mm:", } class BrowsingDataProvider(singleton.Singleton): SESSION_SEC_KEY = "session_sec_key" FIRST_DISPLAY = "first_display" CURRENCY_LOGO = "currency_logo" ALL_CURRENCIES = "all_currencies" HOME = "home" PROFILE = "profile" CONFIGURATION = "configuration" AUTOMATIONS = "automations" PROFILE_SELECTOR = "profile_selector" CACHE_EXPIRATION = constants.DAYS_TO_SECONDS * 14 # use 14 days cache maximum TIMESTAMP = "timestamp" VALUE = "value" def __init__(self): self.browsing_data = {} self.logger = logging.get_logger(self.__class__.__name__) self._load_saved_data() @staticmethod def get_distribution_key(distribution: octobot.enums.OctoBotDistribution, key: str) -> str: return f"{_PREFIX_BY_DISTRIBUTION[distribution.value]}{key}" def get_or_create_session_secret_key(self): try: return self._get_session_secret_key() except KeyError: self._generate_session_secret_key() except Exception as err: self.logger.exception(err, True, f"Unexpected error when reading session key: {err}") self._generate_session_secret_key() return self._get_session_secret_key() def get_and_unset_is_first_display(self, element): try: value = self.browsing_data[self.FIRST_DISPLAY][element] except KeyError: value = True if value: self.set_is_first_display(element, False) return value def set_is_first_display(self, element, is_first_display): try: if self.browsing_data[self.FIRST_DISPLAY][element] != is_first_display: self.browsing_data[self.FIRST_DISPLAY][element] = is_first_display self.dump_saved_data() except KeyError: self.browsing_data[self.FIRST_DISPLAY][element] = is_first_display self.dump_saved_data() def set_first_displays(self, is_first_display): for key in self.browsing_data[self.FIRST_DISPLAY]: self.browsing_data[self.FIRST_DISPLAY][key] = is_first_display self.dump_saved_data() def get_currency_logo_url(self, currency_id): try: return self.browsing_data[self.CURRENCY_LOGO][currency_id] except KeyError: return None def set_currency_logo_url(self, currency_id, url, dump=True): if url is None: # do not save None as an url return self.browsing_data[self.CURRENCY_LOGO][currency_id] = url if dump: self.dump_saved_data() def get_all_currencies(self): return self._get_expiring_cached_value(self.ALL_CURRENCIES) def set_all_currencies(self, all_currencies): self._set_expiring_cached_value(self.ALL_CURRENCIES, all_currencies) self.dump_saved_data() def _get_session_secret_key(self): authenticator = commons_authentication.Authenticator.instance() if ( authenticator.must_be_authenticated_through_authenticator() and not run_in_bot_main_loop(authenticator.has_login_info()) ): # reset session key to force login self.logger.debug("Regenerating session key as user is required but not connected.") self._generate_session_secret_key() return commons_configuration.decrypt(self.browsing_data[self.SESSION_SEC_KEY]).encode() def _create_session_secret_key(self): # always generate a new unique session secret key, reuse it to save sessions after restart # https://flask.palletsprojects.com/en/2.2.x/quickstart/#sessions return commons_configuration.encrypt(secrets.token_hex()).decode() def _generate_session_secret_key(self): self.browsing_data[self.SESSION_SEC_KEY] = self._create_session_secret_key() self.dump_saved_data() def _get_default_data(self): return { self.SESSION_SEC_KEY: self._create_session_secret_key(), self.FIRST_DISPLAY: {}, self.CURRENCY_LOGO: {}, self.ALL_CURRENCIES: self._create_expiring_cached_value([]), } def _apply_saved_data(self, read_data): if not isinstance(read_data[self.ALL_CURRENCIES], dict): # ensure previous version compatibility read_data[self.ALL_CURRENCIES] = self._get_default_data()[self.ALL_CURRENCIES] self.browsing_data.update(read_data) def _load_saved_data(self): self.browsing_data = self._get_default_data() read_data = {} try: read_data = json_util.read_file(self._get_file()) self._apply_saved_data(read_data) except FileNotFoundError: pass except Exception as err: self.logger.exception(err, True, f"Unexpected error when reading saved data: {err}") if any(key not in read_data for key in self.browsing_data): # save fixed data self.dump_saved_data() def dump_saved_data(self): try: with open(self._get_file(), "w") as sessions_file: return json.dump(self.browsing_data, sessions_file) except Exception as err: self.logger.exception(err, True, f"Unexpected error when reading saved data: {err}") def _get_file(self): return os.path.join(constants.USER_FOLDER, f"{self.__class__.__name__}_data.json") def _get_expiring_cached_value(self, key): self._ensure_cache_expiration(key) return self.browsing_data[key][self.VALUE] def _set_expiring_cached_value(self, key, value): self.browsing_data[key] = self._create_expiring_cached_value(value) def _create_expiring_cached_value(self, value): return { self.TIMESTAMP: time.time(), self.VALUE: value, } def _ensure_cache_expiration(self, key): if time.time() - self.browsing_data[key][self.TIMESTAMP] > self.CACHE_EXPIRATION: self.browsing_data[key] = self._get_default_data()[key] ================================================ FILE: Services/Interfaces/web_interface/flask_util/content_types_management.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import mimetypes def init_content_types(): # force mimetypes not to rely on system configuration mimetypes.add_type('text/css', '.css') mimetypes.add_type('application/javascript', '.js') mimetypes.add_type('image/x-icon', '.ico') mimetypes.add_type('image/svg+xml', '.svg') mimetypes.add_type('font/woff2', '.woff2') mimetypes.add_type('font/woff2', '.woff2') ================================================ FILE: Services/Interfaces/web_interface/flask_util/context_processor.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.constants as commons_constants import octobot_commons.authentication as authentication import octobot.constants as constants import octobot.enums as enums import octobot.community.identifiers_provider as identifiers_provider import octobot.community.supabase_backend.enums as community_enums import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.models.configuration as configuration_model import tentacles.Services.Interfaces.web_interface.enums as web_enums import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.constants as web_constants import tentacles.Services.Interfaces.web_interface.login as web_interface_login import octobot_services.interfaces as interfaces import octobot_trading.util as trading_util import octobot_trading.api as trading_api import octobot_trading.enums as trading_enums def register_context_processor(web_interface_instance): @web_interface_instance.server_instance.context_processor def context_processor_register(): def get_color_mode() -> str: return models.get_color_mode().value def get_distribution() -> str: return models.get_distribution().value def get_tentacle_config_file_content(tentacle_class): return models.get_tentacle_config(tentacle_class) def get_exchange_holdings(holdings, holding_type): return ', '.join(f'{exchange.capitalize()}: {holding[holding_type]}' for exchange, holding in holdings['exchanges'].items()) def _get_details_from_full_symbol_list(full_symbol_list, currency_name): for symbol_details in full_symbol_list: if symbol_details[configuration_model.SHORT_NAME_KEY].lower() == currency_name: return symbol_details raise KeyError(currency_name) def get_currency_id(full_symbol_list, currency_name): currency_key = currency_name.lower() try: return _get_details_from_full_symbol_list(full_symbol_list, currency_key)[configuration_model.ID_KEY] except KeyError: return currency_key def filter_currency_pairs(currency, symbol_list_by_type, full_symbol_list, config_symbols): currency_key = currency.lower() try: symbol = _get_details_from_full_symbol_list(full_symbol_list, currency_key)[configuration_model.SYMBOL_KEY] except KeyError: return symbol_list_by_type filtered_symbol = { symbol_type: [ s for s in symbols if symbol in symbol_util.parse_symbol(s).base_and_quote() ] for symbol_type, symbols in symbol_list_by_type.items() } for symbol_type in list(filtered_symbol.keys()): filtered_symbol[symbol_type] += [ s for s in config_symbols[currency][commons_constants.CONFIG_CRYPTO_PAIRS] if s in symbol_list_by_type[symbol_type] and s not in filtered_symbol[symbol_type] ] return filtered_symbol def get_profile_traded_pairs_by_currency(profile): return { currency: val[commons_constants.CONFIG_CRYPTO_PAIRS] for currency, val in profile.config[commons_constants.CONFIG_CRYPTO_CURRENCIES].items() if commons_constants.CONFIG_CRYPTO_PAIRS in val and val[commons_constants.CONFIG_CRYPTO_PAIRS] and trading_util.is_currency_enabled(profile.config, currency, True) } def get_profile_exchanges(profile): return trading_api.get_enabled_exchanges_names(profile.config) def is_supporting_future_trading(supported_exchange_types): return trading_enums.ExchangeTypes.FUTURE in supported_exchange_types def get_enabled_trader(profile): if models.is_real_trading(profile): return "Real trading" if trading_util.is_trader_simulator_enabled(profile.config): return "Simulated trading" return "" def get_filtered_list(origin_list, filtering_list): return [ element for element in origin_list if element in filtering_list ] def get_plugin_tabs(location): has_open_source_package = models.has_open_source_package() for plugin in web_interface_instance.registered_plugins: for tab in plugin.get_tabs(): if tab.location is location and tab.is_available(has_open_source_package): yield tab def is_in_stating_community_env(): return identifiers_provider.IdentifiersProvider.ENABLED_ENVIRONMENT is enums.CommunityEnvironments.Staging def get_enabled_tentacles(tentacles_info_by_name): for name, info in tentacles_info_by_name: if info[web_constants.ACTIVATION_KEY]: return name def get_logged_in_email(): try: return authentication.Authenticator.instance().get_logged_in_email() except (authentication.AuthenticationRequired, authentication.UnavailableError): return "" current_profile = models.get_current_profile() trading_mode = models.get_config_activated_trading_mode() selected_bot = models.get_selected_user_bot() selected_bot_id = (selected_bot.get(community_enums.BotKeys.ID.value) or "") if selected_bot else "" return dict( LAST_UPDATED_STATIC_FILES=web_interface.LAST_UPDATED_STATIC_FILES, OCTOBOT_WEBSITE_URL=constants.OCTOBOT_WEBSITE_URL, OCTOBOT_DOCS_URL=constants.OCTOBOT_DOCS_URL, DEVELOPER_DOCS_URL=constants.DEVELOPER_DOCS_URL, EXCHANGES_DOCS_URL=constants.EXCHANGES_DOCS_URL, OCTOBOT_FEEDBACK_URL=constants.OCTOBOT_FEEDBACK, OCTOBOT_EXTENSION_PACKAGE_1_NAME=constants.OCTOBOT_EXTENSION_PACKAGE_1_NAME, OCTOBOT_COMMUNITY_URL=identifiers_provider.IdentifiersProvider.COMMUNITY_URL, OCTOBOT_COMMUNITY_RECOVER_PASSWORD_URL=identifiers_provider.IdentifiersProvider.FRONTEND_PASSWORD_RECOVER_URL, OCTOBOT_MARKET_MAKING_URL=constants.OCTOBOT_MARKET_MAKING_URL, CURRENT_BOT_VERSION=interfaces.AbstractInterface.project_version, LOCALE=constants.DEFAULT_LOCALE, IS_DEMO=constants.IS_DEMO, IS_CLOUD=constants.IS_CLOUD_ENV, CAN_INSTALL_TENTACLES=constants.CAN_INSTALL_TENTACLES, IS_ALLOWING_TRACKING=models.get_metrics_enabled(), PH_TRACKING_ID=constants.PH_TRACKING_ID, USER_EMAIL=get_logged_in_email(), USER_SELECTED_BOT_ID=selected_bot_id, PROFILE_NAME=current_profile.name, TRADING_MODE_NAME=trading_mode.get_name() if trading_mode else "", EXCHANGE_NAMES=",".join(get_profile_exchanges(current_profile)), IS_REAL_TRADING=models.is_real_trading(current_profile), TAB_START=web_enums.TabsLocation.START, TAB_END=web_enums.TabsLocation.END, get_color_mode=get_color_mode, get_distribution=get_distribution, get_tentacle_config_file_content=get_tentacle_config_file_content, get_currency_id=get_currency_id, filter_currency_pairs=filter_currency_pairs, get_exchange_holdings=get_exchange_holdings, get_profile_traded_pairs_by_currency=get_profile_traded_pairs_by_currency, get_profile_exchanges=get_profile_exchanges, get_enabled_trader=get_enabled_trader, get_filtered_list=get_filtered_list, get_current_profile=models.get_current_profile, get_plugin_tabs=get_plugin_tabs, get_enabled_tentacles=get_enabled_tentacles, is_real_trading=models.is_real_trading, is_supporting_future_trading=is_supporting_future_trading, is_login_required=web_interface_login.is_login_required, is_authenticated=web_interface_login.is_authenticated, is_in_stating_community_env=is_in_stating_community_env, startup_messages=models.get_startup_messages(), are_automations_enabled=models.are_automations_enabled(), is_backtesting_enabled=models.is_backtesting_enabled(), is_advanced_interface_enabled=models.is_advanced_interface_enabled(), has_open_source_package=models.has_open_source_package, critical_notifications=web_interface.get_critical_notifications(), ) ================================================ FILE: Services/Interfaces/web_interface/flask_util/cors.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import octobot_services.constants as services_constants def get_user_defined_cors_allowed_origins(): # Set services_constants.ENV_CORS_ALLOWED_ORIGINS env variable add stricter cors rules allowed origins # example: http://localhost:5000 # Note: you can specify multiple origins using comma as a separator, ex: http://localhost:5000,https://a.com cors_allowed_origins = os.getenv(services_constants.ENV_CORS_ALLOWED_ORIGINS, "*") if "," in cors_allowed_origins: return [origin.strip() for origin in cors_allowed_origins.split(",")] return cors_allowed_origins ================================================ FILE: Services/Interfaces/web_interface/flask_util/file_services.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import os import tentacles.Services.Interfaces.web_interface.models as models def send_and_remove_file(file_path, download_name): try: return flask.send_file(file_path, as_attachment=True, download_name=download_name, max_age=0) finally: # cleanup temp_file def remove_file(file_path): try: os.remove(file_path) except Exception: pass models.schedule_delayed_command(remove_file, file_path, delay=10) ================================================ FILE: Services/Interfaces/web_interface/flask_util/json_provider.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import flask.json.provider class FloatDecimalJSONProvider(flask.json.provider.DefaultJSONProvider): def dumps(self, obj, **kwargs): if isinstance(obj, decimal.Decimal): # Convert decimal instances to float. return float(obj) return super().dumps(obj, **kwargs) ================================================ FILE: Services/Interfaces/web_interface/flask_util/template_filters.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. def register_template_filters(app): # should only be called after app configuration @app.template_filter() def is_dict(value): return isinstance(value, dict) @app.template_filter() def is_list(value): return isinstance(value, list) @app.template_filter() def is_bool(value): return isinstance(value, bool) ================================================ FILE: Services/Interfaces/web_interface/login/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Services.Interfaces.web_interface.login import user from tentacles.Services.Interfaces.web_interface.login.user import ( User, ) from tentacles.Services.Interfaces.web_interface.login import web_login_manager from tentacles.Services.Interfaces.web_interface.login.web_login_manager import ( WebLoginManager, active_login_required, login_required_when_activated, register_attempt, is_banned, reset_attempts, set_is_login_required, is_login_required, is_authenticated, GENERIC_USER, ) __all__ = [ "User", "WebLoginManager", "active_login_required", "login_required_when_activated", "register_attempt", "is_banned", "reset_attempts", "set_is_login_required", "is_login_required", "is_authenticated", "GENERIC_USER", ] ================================================ FILE: Services/Interfaces/web_interface/login/open_source_package_required.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import functools import octobot.constants as constants import tentacles.Services.Interfaces.web_interface.models as models def open_source_package_required(func): @functools.wraps(func) def decorated_view(*args, **kwargs): if models.has_open_source_package(): return func(*args, **kwargs) flask.flash(f"The {constants.OCTOBOT_EXTENSION_PACKAGE_1_NAME} is required to use this page") return flask.redirect(flask.url_for('extensions')) return decorated_view ================================================ FILE: Services/Interfaces/web_interface/login/user.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. class User: """ Docs from https://flask-login.readthedocs.io/en/latest/#your-user-class """ GENERIC_USER_ID = "user" def __init__(self): # This property should return True if the user is authenticated, i.e. they have provided valid credentials. # (Only authenticated users will fulfill the criteria of login_required.) self.is_authenticated = False # This property should return True if this is an active user - in addition to being authenticated, they also # have activated their account, not been suspended, or any condition your application has for rejecting # an account. Inactive accounts may not log in (without being forced of course). self.is_active = True # This property should return True if this is an anonymous user. (Actual users should return False instead.) self.is_anonymous = False def get_id(self): """ This method must return a unicode that uniquely identifies this user, and can be used to load the user from the user_loader callback. Note that this must be a unicode - if the ID is natively an int or some other type, you will need to convert it to unicode. :return: The only octoBot user id """ return self.GENERIC_USER_ID ================================================ FILE: Services/Interfaces/web_interface/login/web_login_manager.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import functools import flask_login import flask import octobot_commons.configuration as configuration import octobot_commons.authentication as authentication import octobot_commons.logging as logging import octobot_services.interfaces.util as interfaces_util import octobot.constants as constants import tentacles.Services.Interfaces.web_interface.login as login GENERIC_USER = login.User() _IS_LOGIN_REQUIRED = True IP_TO_CONNECTION_ATTEMPTS = {} MAX_CONNECTION_ATTEMPTS = 50 class WebLoginManager(flask_login.LoginManager): def __init__(self, flask_app, password_hash): # force is_authenticated to save login state throughout server restart GENERIC_USER.is_authenticated = True flask_login.LoginManager.__init__(self) self.init_app(flask_app) self.password_hash = password_hash # register login view to redirect to when login is required self.login_view = "/login" self._register_callbacks() def login_user(self, remember=False, duration=None, **kwargs): # still set is_authenticated to be sure it's True on login GENERIC_USER.is_authenticated = True flask_login.login_user(GENERIC_USER, remember=remember, duration=duration, **kwargs) def is_valid_password(self, ip, password, form): authenticator = authentication.Authenticator.instance() if authenticator.must_be_authenticated_through_authenticator(): try: if constants.USER_ACCOUNT_EMAIL is None: raise authentication.AuthenticationError("Login impossible. " "USER_ACCOUNT_EMAIL constant must to be set") interfaces_util.run_in_bot_main_loop( authenticator.login(constants.USER_ACCOUNT_EMAIL, password), log_exceptions=False ) return not is_banned(ip) except authentication.FailedAuthentication: return False except Exception as e: logging.get_logger("WebLoginManager").exception(e, False) form.password.errors.append(f"Error during authentication: {e}") return False return not is_banned(ip) and configuration.get_password_hash(password) == self.password_hash def _register_callbacks(self): @self.user_loader def load_user(_): # return None if user is invalid return GENERIC_USER def is_authenticated(): return flask_login.current_user.is_authenticated def set_is_login_required(login_required): global _IS_LOGIN_REQUIRED _IS_LOGIN_REQUIRED = login_required def is_login_required(): return _IS_LOGIN_REQUIRED or authentication.Authenticator.instance().must_be_authenticated_through_authenticator() @flask_login.login_required def _login_required_func(func, *args, **kwargs): return func(*args, **kwargs) def login_required_when_activated(func): @functools.wraps(func) def decorated_view(*args, **kwargs): if is_login_required(): return _login_required_func(func, *args, **kwargs) return func(*args, **kwargs) return decorated_view def active_login_required(func): @functools.wraps(func) def decorated_view(*args, **kwargs): if is_login_required(): return _login_required_func(func, *args, **kwargs) flask.flash(f"For security reasons, please enable password authentication in " f"accounts configuration to use the {flask.request.path} page.", category=flask_login.LOGIN_MESSAGE_CATEGORY) return flask.redirect('home') return decorated_view def register_attempt(ip): if ip in IP_TO_CONNECTION_ATTEMPTS: IP_TO_CONNECTION_ATTEMPTS[ip] += 1 else: IP_TO_CONNECTION_ATTEMPTS[ip] = 1 return not is_banned(ip) def is_banned(ip): if ip in set(IP_TO_CONNECTION_ATTEMPTS.keys()): return IP_TO_CONNECTION_ATTEMPTS[ip] >= MAX_CONNECTION_ATTEMPTS return False def reset_attempts(ip): IP_TO_CONNECTION_ATTEMPTS[ip] = 0 ================================================ FILE: Services/Interfaces/web_interface/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["WebInterface"], "tentacles-requirements": ["web_service"] } ================================================ FILE: Services/Interfaces/web_interface/models/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Services.Interfaces.web_interface.models import backtesting from tentacles.Services.Interfaces.web_interface.models import commands from tentacles.Services.Interfaces.web_interface.models import community from tentacles.Services.Interfaces.web_interface.models import configuration from tentacles.Services.Interfaces.web_interface.models import dashboard from tentacles.Services.Interfaces.web_interface.models import interface_settings from tentacles.Services.Interfaces.web_interface.models import logs from tentacles.Services.Interfaces.web_interface.models import medias from tentacles.Services.Interfaces.web_interface.models import profiles from tentacles.Services.Interfaces.web_interface.models import strategy_optimizer from tentacles.Services.Interfaces.web_interface.models import tentacles from tentacles.Services.Interfaces.web_interface.models import trading from tentacles.Services.Interfaces.web_interface.models import web_interface_tab from tentacles.Services.Interfaces.web_interface.models import json_schemas from tentacles.Services.Interfaces.web_interface.models import distributions from tentacles.Services.Interfaces.web_interface.models import dsl from tentacles.Services.Interfaces.web_interface.models.dsl import ( get_dsl_keywords_docs, ) from tentacles.Services.Interfaces.web_interface.models.backtesting import ( CURRENT_BOT_DATA, get_full_candle_history_exchange_list, get_other_history_exchange_list, get_data_files_with_description, start_backtesting_using_specific_files, stop_previous_backtesting, is_backtesting_enabled, create_snapshot_data_collector, get_data_files_from_current_bot, start_backtesting_using_current_bot_data, get_backtesting_status, get_backtesting_report, get_latest_backtesting_run_id, get_delete_data_file, get_data_collector_status, stop_data_collector, collect_data_file, save_data_file, ) from tentacles.Services.Interfaces.web_interface.models.commands import ( schedule_delayed_command, restart_bot, is_rebooting, stop_bot, update_bot, ) from tentacles.Services.Interfaces.web_interface.models.community import ( get_community_metrics_to_display, can_get_community_metrics, get_owned_packages, has_owned_packages_to_install, has_open_source_package, update_owned_packages, get_checkout_url, get_tradingview_email_address, get_last_email_address_confirm_code_email_content, wait_for_email_address_confirm_code_email, get_cloud_strategies, get_cloud_strategy, get_preview_tentacles_packages, get_current_octobots_stats, get_all_user_bots, get_selected_user_bot, select_bot, create_new_bot, can_select_bot, can_logout, get_user_account_id, has_filled_form, register_user_submitted_form, get_followed_strategy_url, is_community_feed_connected, get_last_signal_time, sync_community_account, wait_for_login_if_processing, ) from tentacles.Services.Interfaces.web_interface.models.json_schemas import ( NAME, JSON_PORTFOLIO_SCHEMA, JSON_TRADING_SIMULATOR_SCHEMA, get_json_simulated_portfolio, get_json_trading_simulator_config, get_json_exchanges_schema, get_json_exchange_config, json_exchange_config_to_config, ) from tentacles.Services.Interfaces.web_interface.models.configuration import ( get_evaluators_tentacles_startup_activation, get_trading_tentacles_startup_activation, get_tentacle_documentation, is_trading_strategy_configuration, get_tentacle_from_string, get_tentacle_user_commands, are_automations_enabled, is_advanced_interface_enabled, restart_global_automations, get_all_automation_steps, has_at_least_one_running_automation, get_automations_count, reset_automation_config_to_default, get_tentacle_config, get_tentacle_config_and_user_inputs, get_tentacle_config_and_edit_display, get_tentacle_config_schema, get_extra_tentacles_config_desc, get_tentacles_activation_desc_by_group, update_tentacle_config, update_copied_trading_id, reset_config_to_default, get_strategy_config, get_in_backtesting_mode, accepted_terms, accept_terms, get_evaluator_detailed_config, get_config_activated_trading_mode, get_config_activated_strategies, get_config_activated_evaluators, has_futures_exchange, update_tentacles_activation_config, get_active_exchanges, update_global_config, activate_metrics, activate_beta_env, get_metrics_enabled, get_beta_env_enabled_in_config, get_services_list, get_notifiers_list, get_enabled_trading_pairs, get_exchange_available_trading_pairs, get_symbol_list, get_all_currencies, get_config_time_frames, get_timeframes_list, get_strategy_required_time_frames, format_config_symbols, format_config_symbols_without_enabled_key, get_all_symbols_list, get_all_symbols_list_by_symbol_type, get_exchange_logo, get_currency_logo_urls, get_traded_time_frames, get_full_exchange_list, get_full_configurable_exchange_list, get_default_exchange, get_tested_exchange_list, get_simulated_exchange_list, get_other_exchange_list, get_enabled_exchange_types, get_exchanges_details, are_compatible_accounts, get_current_exchange, REQUIREMENTS_KEY, SYMBOL_KEY, ID_KEY, TRADING_MODES_KEY, STRATEGIES_KEY, change_reference_market_on_config_currencies, send_command_to_activated_tentacles, send_command_to_tentacles, reload_scripts, reload_activated_tentacles_config, reload_tentacle_config, update_config_currencies, get_config_required_candles_count, get_sandbox_exchanges, get_distribution, ) from tentacles.Services.Interfaces.web_interface.models.dashboard import ( parse_get_symbol, get_value_from_dict_or_string, format_trades, format_orders, get_first_exchange_data, get_watched_symbol_data, get_startup_messages, get_first_symbol_data, get_currency_price_graph_update, ) from tentacles.Services.Interfaces.web_interface.models.interface_settings import ( add_watched_symbol, remove_watched_symbol, get_watched_symbols, get_display_timeframe, set_color_mode, get_color_mode, set_display_announcement, get_display_announcement, get_display_orders, set_display_timeframe, set_display_orders, ) from tentacles.Services.Interfaces.web_interface.models.logs import ( LOG_EXPORT_FORMAT, export_logs, ) from tentacles.Services.Interfaces.web_interface.models.medias import ( is_valid_tentacle_image_path, is_valid_profile_image_path, is_valid_audio_path, ) from tentacles.Services.Interfaces.web_interface.models.profiles import ( get_current_profile, get_profile, get_tentacles_setup_config_from_profile_id, get_tentacles_setup_config_from_profile, duplicate_profile, convert_to_live_profile, select_profile, get_profiles, get_profiles_tentacles_details, update_profile, remove_profile, export_profile, import_profile, download_and_import_profile, import_strategy_as_profile, get_profile_name, get_forced_profile, is_real_trading, ) from tentacles.Services.Interfaces.web_interface.models.strategy_optimizer import ( get_strategies_list, get_time_frames_list, get_evaluators_list, get_risks_list, cancel_optimizer, start_optimizer, get_optimizer_results, get_optimizer_report, get_current_run_params, get_optimizer_status, ) from tentacles.Services.Interfaces.web_interface.models.tentacles import ( get_tentacles_packages, get_official_tentacles_url, call_tentacle_manager, install_packages, update_packages, reset_packages, update_modules, uninstall_modules, get_tentacles, ) from tentacles.Services.Interfaces.web_interface.models.trading import ( ensure_valid_exchange_id, get_exchange_watched_time_frames, get_all_watched_time_frames, get_initializing_currencies_prices_set, get_evaluation, get_exchanges_load, get_exchange_holdings_per_symbol, get_symbols_values, get_portfolio_historical_values, get_pnl_history_symbols, get_pnl_history, get_all_orders_data, get_all_trades_data, get_all_positions_data, clear_exchanges_orders_history, clear_exchanges_trades_history, clear_exchanges_transactions_history, clear_exchanges_portfolio_history, ) from tentacles.Services.Interfaces.web_interface.models.web_interface_tab import ( WebInterfaceTab, ) from tentacles.Services.Interfaces.web_interface.models.distributions import ( save_market_making_configuration, get_market_making_services, ) __all__ = [ "get_data_files_with_description", "start_backtesting_using_specific_files", "stop_previous_backtesting", "is_backtesting_enabled", "create_snapshot_data_collector", "get_data_files_from_current_bot", "start_backtesting_using_current_bot_data", "get_backtesting_status", "get_backtesting_report", "get_latest_backtesting_run_id", "get_delete_data_file", "get_data_collector_status", "stop_data_collector", "collect_data_file", "save_data_file", "schedule_delayed_command", "restart_bot", "is_rebooting", "stop_bot", "update_bot", "get_community_metrics_to_display", "can_get_community_metrics", "get_owned_packages", "has_owned_packages_to_install", "has_open_source_package", "update_owned_packages", "get_checkout_url", "get_tradingview_email_address", "get_last_email_address_confirm_code_email_content", "wait_for_email_address_confirm_code_email", "get_cloud_strategies", "get_cloud_strategy", "get_preview_tentacles_packages", "get_current_octobots_stats", "get_all_user_bots", "get_selected_user_bot", "select_bot", "create_new_bot", "can_select_bot", "can_logout", "get_user_account_id", "has_filled_form", "register_user_submitted_form", "get_followed_strategy_url", "is_community_feed_connected", "get_last_signal_time", "sync_community_account", "wait_for_login_if_processing", "get_evaluators_tentacles_startup_activation", "get_trading_tentacles_startup_activation", "get_tentacle_documentation", "is_trading_strategy_configuration", "get_tentacle_from_string", "get_tentacle_user_commands", "are_automations_enabled", "is_advanced_interface_enabled", "restart_global_automations", "get_all_automation_steps", "has_at_least_one_running_automation", "get_automations_count", "reset_automation_config_to_default", "get_tentacle_config", "get_tentacle_config_and_user_inputs", "get_tentacle_config_and_edit_display", "get_tentacle_config_schema", "get_extra_tentacles_config_desc", "get_tentacles_activation_desc_by_group", "update_tentacle_config", "update_copied_trading_id", "reset_config_to_default", "get_strategy_config", "get_in_backtesting_mode", "accepted_terms", "accept_terms", "get_evaluator_detailed_config", "get_config_activated_trading_mode", "get_config_activated_strategies", "get_config_activated_evaluators", "has_futures_exchange", "update_tentacles_activation_config", "get_active_exchanges", "update_global_config", "activate_metrics", "activate_beta_env", "get_metrics_enabled", "get_beta_env_enabled_in_config", "get_services_list", "get_notifiers_list", "get_enabled_trading_pairs", "get_exchange_available_trading_pairs", "get_symbol_list", "get_all_currencies", "get_config_time_frames", "get_timeframes_list", "get_strategy_required_time_frames", "format_config_symbols", "format_config_symbols_without_enabled_key", "get_all_symbols_list", "get_exchange_logo", "get_all_symbols_list_by_symbol_type", "get_currency_logo_urls", "get_json_simulated_portfolio", "get_traded_time_frames", "get_full_exchange_list", "get_full_configurable_exchange_list", "get_default_exchange", "get_tested_exchange_list", "get_simulated_exchange_list", "get_other_exchange_list", "get_exchanges_details", "get_enabled_exchange_types", "are_compatible_accounts", "get_current_exchange", "parse_get_symbol", "get_value_from_dict_or_string", "format_trades", "format_orders", "get_first_exchange_data", "get_watched_symbol_data", "get_first_symbol_data", "get_currency_price_graph_update", "get_watched_symbols", "get_startup_messages", "add_watched_symbol", "remove_watched_symbol", "get_display_timeframe", "set_color_mode", "get_color_mode", "set_display_announcement", "get_display_announcement", "get_display_orders", "set_display_timeframe", "set_display_orders", "LOG_EXPORT_FORMAT", "export_logs", "is_valid_tentacle_image_path", "is_valid_profile_image_path", "is_valid_audio_path", "get_current_profile", "get_profile", "get_tentacles_setup_config_from_profile_id", "get_tentacles_setup_config_from_profile", "duplicate_profile", "convert_to_live_profile", "select_profile", "get_profiles", "get_profiles_tentacles_details", "update_profile", "remove_profile", "export_profile", "import_profile", "download_and_import_profile", "import_strategy_as_profile", "get_profile_name", "get_forced_profile", "is_real_trading", "get_strategies_list", "get_time_frames_list", "get_evaluators_list", "get_risks_list", "cancel_optimizer", "start_optimizer", "get_optimizer_results", "get_optimizer_report", "get_current_run_params", "get_optimizer_status", "get_tentacles_packages", "get_official_tentacles_url", "call_tentacle_manager", "install_packages", "update_packages", "reset_packages", "update_modules", "uninstall_modules", "get_tentacles", "ensure_valid_exchange_id", "get_exchange_watched_time_frames", "get_all_watched_time_frames", "get_initializing_currencies_prices_set", "get_evaluation", "get_exchanges_load", "REQUIREMENTS_KEY", "SYMBOL_KEY", "ID_KEY", "JSON_PORTFOLIO_SCHEMA", "get_exchange_holdings_per_symbol", "get_symbols_values", "get_portfolio_historical_values", "get_pnl_history_symbols", "get_pnl_history", "get_all_orders_data", "get_all_trades_data", "get_all_positions_data", "clear_exchanges_orders_history", "clear_exchanges_trades_history", "clear_exchanges_transactions_history", "clear_exchanges_portfolio_history", "CURRENT_BOT_DATA", "get_full_candle_history_exchange_list", "get_other_history_exchange_list", "change_reference_market_on_config_currencies", "send_command_to_activated_tentacles", "send_command_to_tentacles", "reload_scripts", "reload_activated_tentacles_config", "reload_tentacle_config", "update_config_currencies", "get_config_required_candles_count", "get_sandbox_exchanges", "get_distribution", "WebInterfaceTab", "save_market_making_configuration", "get_market_making_services", "get_dsl_keywords_docs", ] ================================================ FILE: Services/Interfaces/web_interface/models/backtesting.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import copy import os import asyncio import ccxt import threading import octobot.strategy_optimizer import octobot.api as octobot_api import octobot.limits as octobot_limits import octobot.constants as octobot_constants import octobot_commons.enums as commons_enums import octobot_commons.logging as bot_logging import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.symbols as commons_symbols import octobot_commons.databases as databases import octobot_commons.constants as commons_constants import octobot_backtesting.api as backtesting_api import octobot_tentacles_manager.api as tentacles_manager_api import octobot_backtesting.constants as backtesting_constants import octobot_backtesting.enums as backtesting_enums import octobot_backtesting.collectors as collectors import octobot_services.interfaces.util as interfaces_util import octobot_services.enums as services_enums import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.api as trading_api import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Services.Interfaces.web_interface as web_interface_root import tentacles.Services.Interfaces.web_interface.models.trading as trading_model import tentacles.Services.Interfaces.web_interface.models.profiles as profiles_model import tentacles.Services.Interfaces.web_interface.models.configuration as configuration_model STOPPING_TIMEOUT = 30 CURRENT_BOT_DATA = "current_bot_data" # data collector can be really slow, let it up to 2 hours to run DATA_COLLECTOR_TIMEOUT = 2 * commons_constants.HOURS_TO_SECONDS def get_full_candle_history_exchange_list(): full_exchange_list = list(set(ccxt.exchanges)) return [exchange for exchange in trading_constants.FULL_CANDLE_HISTORY_EXCHANGES if exchange in full_exchange_list] def get_other_history_exchange_list(): return [exchange for exchange in configuration_model.get_full_exchange_list() if exchange not in trading_constants.FULL_CANDLE_HISTORY_EXCHANGES] async def _get_description(data_file, files_with_description): description = await backtesting_api.get_file_description(data_file) if _is_usable_description(description): files_with_description.append((data_file, description)) def _is_usable_description(description): return description is not None \ and description[backtesting_enums.DataFormatKeys.SYMBOLS.value] is not None \ and description[backtesting_enums.DataFormatKeys.TIME_FRAMES.value] is not None async def _retrieve_data_files_with_description(files): files_with_description = [] await asyncio.gather(*[_get_description(data_file, files_with_description) for data_file in files]) return sorted( files_with_description, key=lambda f: f[1][backtesting_enums.DataFormatKeys.TIMESTAMP.value], reverse=True ) def get_data_files_with_description(): files = backtesting_api.get_all_available_data_files() return interfaces_util.run_in_bot_async_executor(_retrieve_data_files_with_description(files)) def start_backtesting_using_specific_files(files, source, reset_tentacle_config=False, run_on_common_part_only=True, start_timestamp=None, end_timestamp=None, trading_type=None, enable_logs=False, auto_stop=False, name=None, collector_start_callback=None, start_callback=None): return _start_backtesting(files, source, reset_tentacle_config=reset_tentacle_config, run_on_common_part_only=run_on_common_part_only, start_timestamp=start_timestamp, end_timestamp=end_timestamp, trading_type=trading_type, use_current_bot_data=False, enable_logs=enable_logs, auto_stop=auto_stop, name=name, collector_start_callback=collector_start_callback, start_callback=start_callback) def start_backtesting_using_current_bot_data(data_source, exchange_id, source, reset_tentacle_config=False, start_timestamp=None, end_timestamp=None, trading_type=None, profile_id=None, enable_logs=False, auto_stop=False, name=None, collector_start_callback=None, start_callback=None): use_current_bot_data = data_source == CURRENT_BOT_DATA files = None if use_current_bot_data else [data_source] return _start_backtesting(files, source, reset_tentacle_config=reset_tentacle_config, run_on_common_part_only=False, start_timestamp=start_timestamp, end_timestamp=end_timestamp, trading_type=trading_type, profile_id=profile_id, use_current_bot_data=use_current_bot_data, exchange_id=exchange_id, enable_logs=enable_logs, auto_stop=auto_stop, name=name, collector_start_callback=collector_start_callback, start_callback=start_callback) def stop_previous_backtesting(): previous_independent_backtesting = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_BACKTESTING] if previous_independent_backtesting \ and not octobot_api.is_independent_backtesting_stopped(previous_independent_backtesting): interfaces_util.run_in_bot_main_loop( octobot_api.stop_independent_backtesting(previous_independent_backtesting) ) return True, "Backtesting is stopping" return True, "No backtesting to stop" def is_backtesting_enabled(): return octobot_constants.ENABLE_BACKTESTING def _parse_trading_type(trading_type): if trading_type is None or trading_type == commons_constants.USE_CURRENT_PROFILE: return commons_constants.USE_CURRENT_PROFILE, commons_constants.USE_CURRENT_PROFILE if trading_type == trading_enums.ExchangeTypes.SPOT.value: return commons_constants.CONFIG_EXCHANGE_SPOT, commons_constants.USE_CURRENT_PROFILE if trading_type == trading_enums.FutureContractType.INVERSE_PERPETUAL.value: return commons_constants.CONFIG_EXCHANGE_FUTURE, trading_enums.FutureContractType.INVERSE_PERPETUAL if trading_type == trading_enums.FutureContractType.LINEAR_PERPETUAL.value: return commons_constants.CONFIG_EXCHANGE_FUTURE, trading_enums.FutureContractType.LINEAR_PERPETUAL if trading_type == trading_enums.ExchangeTypes.MARGIN.value: return commons_constants.CONFIG_EXCHANGE_MARGIN, commons_constants.USE_CURRENT_PROFILE raise RuntimeError(f"Unsupported trading type: {trading_type}") def _start_backtesting(files, source, reset_tentacle_config=False, run_on_common_part_only=True, start_timestamp=None, end_timestamp=None, trading_type=None, profile_id=None, use_current_bot_data=False, exchange_id=None, enable_logs=False, auto_stop=False, collector_start_callback=None, start_callback=None, name=None): tools = web_interface_root.WebInterface.tools if exchange_id is not None: trading_model.ensure_valid_exchange_id(exchange_id) try: previous_independent_backtesting = tools[constants.BOT_TOOLS_BACKTESTING] optimizer = tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] is_optimizer_running = tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] and \ interfaces_util.run_in_bot_async_executor( octobot_api.is_optimizer_in_progress(tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER]) ) if is_optimizer_running and not isinstance(optimizer, octobot.strategy_optimizer.StrategyDesignOptimizer): return False, "An optimizer is already running" if use_current_bot_data and \ isinstance(tools[constants.BOT_TOOLS_DATA_COLLECTOR], collectors.AbstractExchangeBotSnapshotCollector): # can't start a new backtest with use_current_bot_data when a snapshot collector is on return False, "An data collector is already running" if tools[constants.BOT_PREPARING_BACKTESTING]: return False, "An backtesting is already running" if previous_independent_backtesting and \ octobot_api.is_independent_backtesting_in_progress(previous_independent_backtesting): return False, "A backtesting is already running" else: tools[constants.BOT_PREPARING_BACKTESTING] = True if previous_independent_backtesting: interfaces_util.run_in_bot_main_loop( octobot_api.stop_independent_backtesting(previous_independent_backtesting) ) profile = profiles_model.get_current_profile() if profile_id is not None: profile = profiles_model.get_profile(profile_id) config = profiles_model.get_profile(profile_id).config tentacles_setup_config = profiles_model.get_tentacles_setup_config_from_profile_id(profile_id) else: if reset_tentacle_config: tentacles_config = interfaces_util.get_edited_config(dict_only=False).get_tentacles_config_path() tentacles_setup_config = tentacles_manager_api.get_tentacles_setup_config(tentacles_config) else: tentacles_setup_config = interfaces_util.get_bot_api().get_edited_tentacles_config() config = interfaces_util.get_edited_config() # do not edit original config dict config = copy.copy(config) exchange_type, contract_type = _parse_trading_type(trading_type) config[commons_constants.CONFIG_EXCHANGE_TYPE] = exchange_type config[commons_constants.CONFIG_CONTRACT_TYPE] = contract_type config[commons_constants.CONFIG_REQUIRED_EXTRA_TIMEFRAMES] = profile.extra_backtesting_time_frames tools[constants.BOT_TOOLS_BACKTESTING_SOURCE] = source if is_optimizer_running and files is None: files = [get_data_files_from_current_bot(exchange_id, start_timestamp, end_timestamp, collect=False, profile_id=profile_id)] if not is_optimizer_running and use_current_bot_data: tools[constants.BOT_TOOLS_DATA_COLLECTOR] = \ create_snapshot_data_collector(exchange_id, start_timestamp, end_timestamp, profile_id=profile_id) tools[constants.BOT_TOOLS_BACKTESTING] = None else: tools[constants.BOT_TOOLS_BACKTESTING] = octobot_api.create_independent_backtesting( config, tentacles_setup_config, files, run_on_common_part_only=run_on_common_part_only, start_timestamp=start_timestamp / 1000 if start_timestamp else None, end_timestamp=end_timestamp / 1000 if end_timestamp else None, enable_logs=enable_logs, stop_when_finished=auto_stop, name=name ) tools[constants.BOT_TOOLS_DATA_COLLECTOR] = None interfaces_util.run_in_bot_main_loop( _collect_initialize_and_run_independent_backtesting( tools[constants.BOT_TOOLS_DATA_COLLECTOR], tools[constants.BOT_TOOLS_BACKTESTING], config, tentacles_setup_config, files, run_on_common_part_only, start_timestamp, end_timestamp, enable_logs, auto_stop, name, collector_start_callback, start_callback), blocking=False, timeout=DATA_COLLECTOR_TIMEOUT) return True, "Backtesting started" except Exception as e: bot_logging.get_logger("DataCollectorWebInterfaceModel").exception(e, False) return False, f"Error when starting backtesting: {e}" finally: tools[constants.BOT_PREPARING_BACKTESTING] = False async def _collect_initialize_and_run_independent_backtesting( data_collector_instance, independent_backtesting, config, tentacles_setup_config, files, run_on_common_part_only, start_timestamp, end_timestamp, enable_logs, auto_stop, name, collector_start_callback, start_callback): logger = bot_logging.get_logger("StartIndependentBacktestingModel") if data_collector_instance is not None: try: if collector_start_callback: collector_start_callback() files = [await backtesting_api.initialize_and_run_data_collector(data_collector_instance)] except Exception as e: bot_logging.get_logger("DataCollectorModel").exception( e, True, f"Error when collecting historical data: {e}") web_interface_root.WebInterface.tools[constants.BOT_PREPARING_BACKTESTING] = False return finally: web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] = None web_interface_root.WebInterface.tools[constants.BOT_TOOLS_BACKTESTING] = None if independent_backtesting is None: try: if files is None: raise RuntimeError("No datafiles") independent_backtesting = octobot_api.create_independent_backtesting( config, tentacles_setup_config, files, run_on_common_part_only=run_on_common_part_only, start_timestamp=start_timestamp / 1000 if start_timestamp else None, end_timestamp=end_timestamp / 1000 if end_timestamp else None, enable_logs=enable_logs, stop_when_finished=auto_stop, name=name ) except Exception as e: logger.exception(e, True, f"Error when initializing backtesting: {e}") finally: # only unregister collector now that we can associate a backtesting web_interface_root.WebInterface.tools[constants.BOT_TOOLS_BACKTESTING] = independent_backtesting web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] = None try: web_interface_root.WebInterface.tools[constants.BOT_PREPARING_BACKTESTING] = False if files is not None: if start_callback: start_callback() await octobot_api.initialize_and_run_independent_backtesting(independent_backtesting, log_errors=False) else: logger.error(f"Data files is None when initializing backtesting: impossible to start") except Exception as e: message = f"Error when running backtesting: {e}" logger.exception(e, True, message) await web_interface_root.add_notification(services_enums.NotificationLevel.ERROR, "Backtesting", message) try: await octobot_api.stop_independent_backtesting(independent_backtesting) web_interface_root.WebInterface.tools[constants.BOT_TOOLS_BACKTESTING] = None except Exception as e: logger.exception(e, True, f"Error when stopping backtesting: {e}") def get_backtesting_status(): if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_BACKTESTING] is not None: independent_backtesting = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_BACKTESTING] if octobot_api.is_independent_backtesting_in_progress(independent_backtesting): return "computing", octobot_api.get_independent_backtesting_progress(independent_backtesting) * 100, \ bot_logging.get_backtesting_errors_count() if octobot_api.is_independent_backtesting_finished(independent_backtesting) or \ octobot_api.is_independent_backtesting_stopped(independent_backtesting): return "finished", 100, bot_logging.get_backtesting_errors_count() return "starting", 0, 0 return "not started", 0, 0 def get_backtesting_report(source): tools = web_interface_root.WebInterface.tools if tools[constants.BOT_TOOLS_BACKTESTING]: independent_backtesting = tools[constants.BOT_TOOLS_BACKTESTING] if tools[constants.BOT_TOOLS_BACKTESTING_SOURCE] == source: return { "report": interfaces_util.run_in_bot_async_executor( octobot_api.get_independent_backtesting_report(independent_backtesting) ), "trades": trading_model.get_all_trades_data(independent_backtesting=independent_backtesting) } return {} def get_latest_backtesting_run_id(trading_mode): tools = web_interface_root.WebInterface.tools if tools[constants.BOT_TOOLS_BACKTESTING]: backtesting = tools[constants.BOT_TOOLS_BACKTESTING] interfaces_util.run_in_bot_main_loop(octobot_api.join_independent_backtesting_stop(backtesting, STOPPING_TIMEOUT)) bot_id = octobot_api.get_independent_backtesting_bot_id(backtesting) return { "id": databases.RunDatabasesProvider.instance().get_run_databases_identifier( bot_id ).backtesting_id } return {} def get_delete_data_file(file_name): deleted, error = backtesting_api.delete_data_file(file_name) if deleted: return deleted, f"{file_name} deleted" else: return deleted, f"Can't delete {file_name} ({error})" def get_data_collector_status(): progress = {"current_step": 0, "total_steps": 0, "current_step_percent": 0} if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] is not None: data_collector = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] if backtesting_api.is_data_collector_in_progress(data_collector): current_step, total_steps, current_step_percent = \ backtesting_api.get_data_collector_progress(data_collector) progress["current_step"] = current_step progress["total_steps"] = total_steps progress["current_step_percent"] = current_step_percent return "collecting", progress if backtesting_api.is_data_collector_finished(data_collector): return "finished", progress return "starting", progress return "not started", progress def stop_data_collector(): success = False message = "Failed to stop data collector" if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] is not None: success = interfaces_util.run_in_bot_main_loop(backtesting_api.stop_data_collector(web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR])) message = "Data collector stopped" web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] = None return success, message def create_snapshot_data_collector(exchange_id, start_timestamp, end_timestamp, profile_id=None): exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) symbols = trading_api.get_trading_symbols(exchange_manager) time_frames = trading_api.get_relevant_time_frames(exchange_manager) if profile_id is not None: profile = profiles_model.get_profile(profile_id) tentacles_setup_config = profiles_model.get_tentacles_setup_config_from_profile(profile) strategies = configuration_model.get_config_activated_strategies(tentacles_setup_config) time_frames = list(set( [ tf for tf in configuration_model.get_traded_time_frames( exchange_manager, strategies=strategies, tentacles_setup_config=tentacles_setup_config ) or (commons_enums.TimeFrames.ONE_MINUTE,) ] + [ commons_enums.TimeFrames(tf) for tf in profile.extra_backtesting_time_frames ] )) exchange_symbols = trading_api.get_all_exchange_symbols(exchange_manager) profile_symbols = trading_api.get_config_symbols(profile.config, True) symbols = [ commons_symbols.parse_symbol(symbol) for symbol in profile_symbols if symbol in exchange_symbols ] if len(symbols) < len(profile_symbols): skipped = [ symbol for symbol in profile_symbols if commons_symbols.parse_symbol(symbol) not in symbols ] bot_logging.get_logger("DataCollectorWebInterfaceModel").error( f"Skipping {skipped} symbol(s) for backtesting as they " f"are not available on {trading_api.get_exchange_name(exchange_manager)}" ) return backtesting_api.exchange_bot_snapshot_data_collector_factory( trading_api.get_exchange_name(exchange_manager), interfaces_util.get_bot_api().get_edited_tentacles_config(), symbols, exchange_id, time_frames=time_frames, start_timestamp=start_timestamp, end_timestamp=end_timestamp, config=interfaces_util.get_bot_api().get_edited_config(dict_only=True), ) def get_data_files_from_current_bot(exchange_id, start_timestamp, end_timestamp, collect=True, profile_id=None): data_collector_instance = create_snapshot_data_collector(exchange_id, start_timestamp, end_timestamp, profile_id=profile_id) if not collect: return data_collector_instance.file_name web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] = data_collector_instance try: collected_files = interfaces_util.run_in_bot_main_loop( backtesting_api.initialize_and_run_data_collector(data_collector_instance), timeout=DATA_COLLECTOR_TIMEOUT ) return collected_files finally: web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] = None def collect_data_file(exchange, symbols, time_frames=None, start_timestamp=None, end_timestamp=None): if not is_backtesting_enabled(): return False, "Backtesting is disabled." if not exchange: return False, "Please select an exchange." if not symbols: return False, "Please select a trading pair." if message := _ensure_backtesting_limits( exchange, symbols, time_frames, start_timestamp, end_timestamp, "collect data" ): return False, message if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] is None or \ backtesting_api.is_data_collector_finished( web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR]): if time_frames is not None: time_frames = time_frames if isinstance(time_frames, list) else [time_frames] if not any(isinstance(time_frame, commons_enums.TimeFrames) for time_frame in time_frames): time_frames = time_frame_manager.parse_time_frames(time_frames) first_symbol = commons_symbols.parse_symbol(symbols[0]) exchange_type = trading_enums.ExchangeTypes.SPOT if first_symbol.is_spot() \ else trading_enums.ExchangeTypes.FUTURE if first_symbol.is_future() \ else trading_enums.ExchangeTypes.UNKNOWN _background_collect_exchange_historical_data(exchange, exchange_type, symbols, time_frames, start_timestamp, end_timestamp) return True, f"Historical data collection started." else: return False, f"Can't collect data for {symbols} on {exchange} (Historical data collector is already running)" async def _start_collect_and_notify(data_collector_instance): success = False message = "finished" try: await backtesting_api.initialize_and_run_data_collector(data_collector_instance) success = True except Exception as e: message = f"error: {e}" notification_level = services_enums.NotificationLevel.SUCCESS if success else services_enums.NotificationLevel.ERROR await web_interface_root.add_notification(notification_level, f"Data collection", message) def _background_collect_exchange_historical_data(exchange, exchange_type, symbols, time_frames, start_timestamp, end_timestamp): data_collector_instance = backtesting_api.exchange_historical_data_collector_factory( exchange, exchange_type, interfaces_util.get_bot_api().get_edited_tentacles_config(), [commons_symbols.parse_symbol(symbol) for symbol in symbols], time_frames=time_frames, start_timestamp=start_timestamp, end_timestamp=end_timestamp, config=interfaces_util.get_bot_api().get_edited_config(dict_only=True), ) web_interface_root.WebInterface.tools[constants.BOT_TOOLS_DATA_COLLECTOR] = data_collector_instance coro = _start_collect_and_notify(data_collector_instance) threading.Thread(target=asyncio.run, args=(coro,), name=f"DataCollector{symbols}").start() async def _convert_into_octobot_data_file_if_necessary(output_file): try: description = await backtesting_api.get_file_description(output_file, data_path="") if description is not None: # no error: current bot format data return f"{output_file} saved" else: # try to convert into current bot format converted_output_file = await backtesting_api.convert_data_file(output_file) if converted_output_file is not None: message = f"Saved into {converted_output_file}" else: message = "Failed to convert file." # remove invalid format file os.remove(output_file) return message except Exception as e: message = f"Error when handling backtesting data file: {e}" bot_logging.get_logger("DataCollectorWebInterfaceModel").exception(e, True, message) return message def save_data_file(name, file): try: output_file = f"{backtesting_constants.BACKTESTING_FILE_PATH}/{name}" file.save(output_file) message = interfaces_util.run_in_bot_async_executor(_convert_into_octobot_data_file_if_necessary(output_file)) bot_logging.get_logger("DataCollectorWebInterfaceModel").info(message) return True, message except Exception as e: message = f"Error when saving file: {e}. File can't be saved." bot_logging.get_logger("DataCollectorWebInterfaceModel").error(message) return False, message def _ensure_backtesting_limits(exchange, symbols, time_frames, start_timestamp, end_timestamp, action): message = "" try: start_timestamp = start_timestamp / commons_constants.MSECONDS_TO_SECONDS if start_timestamp else start_timestamp end_timestamp = end_timestamp / commons_constants.MSECONDS_TO_SECONDS if end_timestamp else end_timestamp octobot_limits.ensure_backtesting_limits([exchange], symbols, time_frames, start_timestamp, end_timestamp) except octobot_limits.ReachedLimitError as err: message = f"Can't {action} for {symbols} on {exchange}: {err}" bot_logging.get_logger("BacktestingModel").error(message) return message ================================================ FILE: Services/Interfaces/web_interface/models/commands.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import threading import time import octobot_services.interfaces.util as interfaces_util _PENDING_COMMANDS = set() _REBOOT = "reboot" def schedule_delayed_command(command, *args, delay=0.5): def _delayed_command(): time.sleep(delay) command(*args) threading.Thread(target=_delayed_command).start() def restart_bot(delay=None): _PENDING_COMMANDS.add(_REBOOT) if delay: # recall self with delay schedule_delayed_command(restart_bot, delay=delay) return _PENDING_COMMANDS.remove(_REBOOT) interfaces_util.get_bot_api().restart_bot() def is_rebooting(): return _REBOOT in _PENDING_COMMANDS def stop_bot(): interfaces_util.get_bot_api().stop_bot() def update_bot(): interfaces_util.get_bot_api().update_bot() ================================================ FILE: Services/Interfaces/web_interface/models/community.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import typing import octobot_services.interfaces.util as interfaces_util import octobot.community as octobot_community import octobot.commands as octobot_commands import octobot.constants as octobot_constants import octobot_commons.authentication as authentication import octobot_trading.api as trading_api def get_community_metrics_to_display(): return interfaces_util.run_in_bot_async_executor(octobot_community.get_community_metrics()) def can_get_community_metrics(): return octobot_community.can_read_metrics(interfaces_util.get_edited_config(dict_only=False)) def get_owned_packages() -> list[str]: authenticator = authentication.Authenticator.instance() return authenticator.get_owned_packages() def has_owned_packages_to_install() -> list[str]: authenticator = authentication.Authenticator.instance() return authenticator.has_owned_packages_to_install() def update_owned_packages(): authenticator = authentication.Authenticator.instance() interfaces_util.run_in_bot_main_loop(authenticator.fetch_private_data(reset=True)) def has_open_source_package() -> bool: authenticator = authentication.Authenticator.instance() return authenticator.has_open_source_package() def get_checkout_url(payment_method, redirect_url) -> (bool, str): selected_payment_method = "crypto" if payment_method == "crypto" else "credit_card" authenticator = authentication.Authenticator.instance() try: url = interfaces_util.run_in_bot_main_loop(authenticator.fetch_checkout_url(selected_payment_method, redirect_url)) return True, url except BaseException: return False, "error when fetching checkout url" def get_tradingview_email_address() -> str: return authentication.Authenticator.instance().get_saved_tradingview_email() def get_last_email_address_confirm_code_email_content() -> typing.Optional[str]: return authentication.Authenticator.instance().get_last_email_address_confirm_code_email_content() def wait_for_email_address_confirm_code_email(): return interfaces_util.run_in_bot_main_loop( authentication.Authenticator.instance().trigger_wait_for_email_address_confirm_code_email() ) def get_cloud_strategies(authenticator) -> list[octobot_community.StrategyData]: return interfaces_util.run_in_bot_main_loop(authenticator.get_strategies()) def get_cloud_strategy(authenticator, strategy_id: str) -> octobot_community.StrategyData: return interfaces_util.run_in_bot_main_loop(authenticator.get_strategy(strategy_id)) def get_preview_tentacles_packages(url_for): c1 = octobot_community.CommunityTentaclesPackage( "AI candles analyser", "Tentacles packages offering artificial intelligence analysis tools based on candles shapes.", None, True, [url_for("static", filename="img/community/tentacles_packages_previews/octobot.png")], None, None, None) c1.uninstalled = False c2 = octobot_community.CommunityTentaclesPackage( "Telegram portfolio management", "Manage your portfolio directly from the telegram interface.", None, False, [url_for("static", filename="img/community/tentacles_packages_previews/telegram.png")], None, None, None) c2.uninstalled = False c3 = octobot_community.CommunityTentaclesPackage( "Mobile first web interface", "Use a mobile oriented interface for your OctoBot.", None, True, [url_for("static", filename="img/community/tentacles_packages_previews/mobile.png")], None, None, None) c3.uninstalled = True return [c1, c2, c3] def get_current_octobots_stats(): return interfaces_util.run_in_bot_async_executor(octobot_community.get_current_octobots_stats()) def _format_bot(bot): return { "name": octobot_community.CommunityUserAccount.get_bot_name_or_id(bot) if bot else None, "id": octobot_community.CommunityUserAccount.get_bot_id(bot) if bot else None, } def get_all_user_bots(): # reload user bots to make sure the list is up to date interfaces_util.run_in_bot_main_loop(authentication.Authenticator.instance().load_user_bots()) return sorted([ _format_bot(bot) for bot in authentication.Authenticator.instance().user_account.get_all_user_bots_raw_data() ], key=lambda d: d["name"]) def get_selected_user_bot(): return _format_bot(authentication.Authenticator.instance().user_account.get_selected_bot_raw_data()) def select_bot(bot_id): interfaces_util.run_in_bot_main_loop(authentication.Authenticator.instance().select_bot(bot_id)) def create_new_bot(): return interfaces_util.run_in_bot_main_loop(authentication.Authenticator.instance().create_new_bot()) def can_select_bot(): return not octobot_constants.COMMUNITY_BOT_ID def can_logout(): return not authentication.Authenticator.instance().must_be_authenticated_through_authenticator() def get_user_account_id(): return authentication.Authenticator.instance().get_user_id() def has_filled_form(form_id): return authentication.Authenticator.instance().has_filled_form(form_id) def register_user_submitted_form(user_id, form_id): try: if get_user_account_id() != user_id: return False, "Invalid user id" interfaces_util.run_in_bot_main_loop( authentication.Authenticator.instance().register_filled_form(form_id) ) except Exception as e: return False, f"Error when registering filled form {e}" return True, "Thank you for your feedback !" def get_followed_strategy_url(): trading_mode = interfaces_util.get_bot_api().get_trading_mode() if trading_mode is None: return None identifier = trading_api.get_trading_mode_followed_strategy_signals_identifier(trading_mode) if identifier is None: return None return authentication.Authenticator.instance().get_signal_community_url( identifier ) def is_community_feed_connected(): return authentication.Authenticator.instance().is_feed_connected() def get_last_signal_time(): return authentication.Authenticator.instance().get_feed_last_message_time() async def _sync_community_account(): profile_urls = await authentication.Authenticator.instance().get_subscribed_profile_urls() return octobot_commands.download_missing_profiles(interfaces_util.get_edited_config(dict_only=False), profile_urls) def sync_community_account(): return interfaces_util.run_in_bot_main_loop(_sync_community_account()) def wait_for_login_if_processing(): try: interfaces_util.run_in_bot_main_loop(authentication.Authenticator.instance().wait_for_login_if_processing()) except asyncio.TimeoutError: pass ================================================ FILE: Services/Interfaces/web_interface/models/configuration.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import logging import os.path as path import ccxt import ccxt.async_support import copy import requests.adapters import urllib3.util.retry import typing import gc import octobot_evaluators.constants as evaluators_constants import octobot_evaluators.evaluators as evaluators import octobot_evaluators.api as evaluators_api import octobot_services.api as services_api import octobot_services.constants as services_constants import octobot_services.interfaces.util as interfaces_util import octobot_tentacles_manager.api as tentacles_manager_api import octobot_tentacles_manager.constants as tentacles_manager_constants import octobot_trading.api as trading_api import octobot_trading.constants as trading_constants import octobot_trading.modes as trading_modes import octobot_trading.exchanges as trading_exchanges import octobot_trading.storage as trading_storage import octobot_trading.enums as trading_enums import octobot_commons.constants as commons_constants import octobot_commons.logging as bot_logging import octobot_commons.enums as commons_enums import octobot_commons.databases as commons_databases import octobot_commons.configuration as configuration import octobot_commons.tentacles_management as tentacles_management import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.authentication as authentication import octobot_commons.symbols as commons_symbols import octobot_commons.display as display import octobot_commons.errors as commons_errors import octobot_commons.aiohttp_util as aiohttp_util import octobot_commons.html_util as html_util import octobot_commons import octobot_backtesting.api as backtesting_api import octobot.community as community import octobot.constants as octobot_constants import octobot.enums as octobot_enums import octobot.configuration_manager as configuration_manager import octobot.databases_util as octobot_databases_util import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.plugins as web_plugins NAME_KEY = "name" SHORT_NAME_KEY = "n" SYMBOL_KEY = "s" ID_KEY = "i" EXCLUDED_CURRENCY_SUBNAME = tuple(("X Long", "X Short")) DESCRIPTION_KEY = "description" REQUIREMENTS_KEY = "requirements" COMPATIBLE_TYPES_KEY = "compatible-types" REQUIREMENTS_COUNT_KEY = "requirements-min-count" DEFAULT_CONFIG_KEY = "default-config" TRADING_MODES_KEY = "trading-modes" STRATEGIES_KEY = "strategies" TRADING_MODE_KEY = "trading mode" EXCHANGE_KEY = "exchange" WEB_PLUGIN_KEY = "web plugin" STRATEGY_KEY = "strategy" TA_EVALUATOR_KEY = "technical evaluator" SOCIAL_EVALUATOR_KEY = "social evaluator" RT_EVALUATOR_KEY = "real time evaluator" SCRIPTED_EVALUATOR_KEY = "scripted evaluator" REQUIRED_KEY = "required" SOCIAL_KEY = "social" TA_KEY = "ta" RT_KEY = "real-time" SCRIPTED_KEY = "scripted" ACTIVATED_STRATEGIES = "activated_strategies" BASE_CLASSES_KEY = "base_classes" EVALUATION_FORMAT_KEY = "evaluation_format" CONFIG_KEY = "config" DISPLAYED_ELEMENTS_KEY = "displayed_elements" # tentacles from which configuration is not handled in strategies / evaluators configuration and that can be groupped GROUPPABLE_NON_TRADING_STRATEGY_RELATED_TENTACLES = [ tentacles_manager_constants.TENTACLES_BACKTESTING_PATH, tentacles_manager_constants.TENTACLES_SERVICES_PATH, tentacles_manager_constants.TENTACLES_TRADING_PATH ] # tentacles for which configuration can be done in the tentacles tab of profile config EXTRA_CONFIGURABLE_TENTACLES_TYPES = [ tentacles_manager_constants.TENTACLES_INTERFACES_PATH ] _TENTACLE_CONFIG_CACHE = {} DEFAULT_EXCHANGE = "binance" MERGED_CCXT_EXCHANGES = { result.__name__: [merged_exchange.__name__ for merged_exchange in merged] for result, merged in ( (ccxt.async_support.kucoin, (ccxt.async_support.kucoinfutures, )), (ccxt.async_support.binance, (ccxt.async_support.binanceusdm, ccxt.async_support.binancecoinm)), (ccxt.async_support.htx, (ccxt.async_support.huobi, )), ) } REMOVED_CCXT_EXCHANGES = set().union(*(set(v) for v in MERGED_CCXT_EXCHANGES.values())) _FULL_EXCHANGE_LIST: typing.List[str] = None # type: ignore # should be accessed through get_or_init_FULL_EXCHANGE_LIST AUTO_FILLED_EXCHANGES = None def _get_currency_dict(name, symbol, identifier): return { SHORT_NAME_KEY: name, SYMBOL_KEY: symbol.upper(), ID_KEY: identifier } # buffers to faster config page loading markets_by_exchanges = {} all_symbols_dict = {} exchange_logos = {} # can't fetch symbols from coinmarketcap.com (which is in ccxt but is not an exchange and has a paid api) exchange_symbol_fetch_blacklist = {"coinmarketcap"} _LOGGER = None def _get_logger(): global _LOGGER if _LOGGER is None: _LOGGER = bot_logging.get_logger("WebConfigurationModel") return _LOGGER def _get_evaluators_tentacles_activation(): try: return tentacles_manager_api.get_tentacles_activation(interfaces_util.get_edited_tentacles_config())[ tentacles_manager_constants.TENTACLES_EVALUATOR_PATH] except KeyError: return {} def _get_trading_tentacles_activation(): try: return tentacles_manager_api.get_tentacles_activation(interfaces_util.get_edited_tentacles_config())[ tentacles_manager_constants.TENTACLES_TRADING_PATH] except KeyError: return {} def _get_services_tentacles_activation(): try: return tentacles_manager_api.get_tentacles_activation(interfaces_util.get_edited_tentacles_config())[ tentacles_manager_constants.TENTACLES_SERVICES_PATH] except KeyError: return {} def get_evaluators_tentacles_startup_activation(): try: return tentacles_manager_api.get_tentacles_activation(interfaces_util.get_startup_tentacles_config())[ tentacles_manager_constants.TENTACLES_EVALUATOR_PATH] except KeyError: return {} def get_trading_tentacles_startup_activation(): try: return tentacles_manager_api.get_tentacles_activation(interfaces_util.get_startup_tentacles_config())[ tentacles_manager_constants.TENTACLES_TRADING_PATH] except KeyError: return {} def get_tentacle_documentation(name, media_url, missing_tentacles: set = None): try: doc_content = tentacles_manager_api.get_tentacle_documentation(name) if doc_content: resource_url = \ f"{media_url}/{tentacles_manager_api.get_tentacle_resources_path(name).replace(path.sep, '/')}/" # patch resources paths into the tentacle resource path return doc_content.replace(f"\n\n", "

")\ .replace(f"{tentacles_manager_constants.TENTACLE_RESOURCES}/", resource_url) except KeyError as e: if missing_tentacles is None or name not in missing_tentacles: _get_logger().error(f"Impossible to load tentacle documentation for {name} ({e.__class__.__name__}: {e}). " f"This is probably an issue with the {name} tentacle matadata.json file, please " f"make sure this file is accurate and is referring {name} in the 'tentacles' list.") return "" except TypeError: # can happen when tentacles metadata.json are invalid return "" def _get_strategy_activation_state( with_trading_modes, media_url, missing_tentacles: set, whitelist=None, backtestable_only=False ): import tentacles.Trading.Mode as modes import tentacles.Evaluator.Strategies as strategies strategy_config = { TRADING_MODES_KEY: {}, STRATEGIES_KEY: {} } strategy_config_classes = { TRADING_MODES_KEY: {}, STRATEGIES_KEY: {} } if with_trading_modes: trading_config = _get_trading_tentacles_activation() for key, val in trading_config.items(): if whitelist and key not in whitelist: continue config_class = tentacles_management.get_class_from_string( key, trading_modes.AbstractTradingMode, modes, tentacles_management.trading_mode_parent_inspection ) if config_class: if not backtestable_only or (backtestable_only and config_class.is_backtestable()): strategy_config[TRADING_MODES_KEY][key] = {} strategy_config[TRADING_MODES_KEY][key][constants.ACTIVATION_KEY] = val strategy_config[TRADING_MODES_KEY][key][DESCRIPTION_KEY] = get_tentacle_documentation( key, media_url ) strategy_config_classes[TRADING_MODES_KEY][key] = config_class else: _add_to_missing_tentacles_if_missing(key, missing_tentacles) evaluator_config = _get_evaluators_tentacles_activation() for key, val in evaluator_config.items(): if whitelist and key not in whitelist: continue config_class = tentacles_management.get_class_from_string(key, evaluators.StrategyEvaluator, strategies, tentacles_management.evaluator_parent_inspection) if config_class: strategy_config[STRATEGIES_KEY][key] = {} strategy_config[STRATEGIES_KEY][key][constants.ACTIVATION_KEY] = val strategy_config[STRATEGIES_KEY][key][DESCRIPTION_KEY] = get_tentacle_documentation(key, media_url) strategy_config_classes[STRATEGIES_KEY][key] = config_class else: _add_to_missing_tentacles_if_missing(key, missing_tentacles) return strategy_config, strategy_config_classes def _add_to_missing_tentacles_if_missing(tentacle_name: str, missing_tentacles: set): # if tentacle_name can't be accessed in tentacles manager, this tentacle is not available try: tentacles_manager_api.get_tentacle_version(tentacle_name) except KeyError: missing_tentacles.add(tentacle_name) except AttributeError: _get_logger().debug(f"Missing tentacles data for {tentacle_name}. This is likely due to an error in the " f"associated metadata.json file.") missing_tentacles.add(tentacle_name) def _get_tentacle_packages(): import tentacles.Trading.Mode as modes yield modes, trading_modes.AbstractTradingMode, TRADING_MODE_KEY import tentacles.Evaluator.Strategies as strategies yield strategies, evaluators.StrategyEvaluator, STRATEGY_KEY import tentacles.Evaluator.TA as ta yield ta, evaluators.AbstractEvaluator, TA_EVALUATOR_KEY import tentacles.Evaluator.Social as social yield social, evaluators.AbstractEvaluator, SOCIAL_EVALUATOR_KEY import tentacles.Evaluator.RealTime as rt yield rt, evaluators.AbstractEvaluator, RT_EVALUATOR_KEY import tentacles.Evaluator.Scripted as scripted yield scripted, evaluators.ScriptedEvaluator, SCRIPTED_EVALUATOR_KEY import tentacles.Trading.Exchange as exchanges yield exchanges, trading_exchanges.AbstractExchange, EXCHANGE_KEY import tentacles.Services.Interfaces as interfaces yield interfaces, web_plugins.AbstractWebInterfacePlugin, WEB_PLUGIN_KEY def _get_activation_state(name, activation_states): return name in activation_states and activation_states[name] def is_trading_strategy_configuration(tentacle_type): return tentacle_type in ( SCRIPTED_EVALUATOR_KEY, RT_EVALUATOR_KEY, SOCIAL_EVALUATOR_KEY, TA_EVALUATOR_KEY, STRATEGY_KEY, TRADING_MODE_KEY ) def get_tentacle_from_string(name, media_url, with_info=True): for package, abstract_class, tentacle_type in _get_tentacle_packages(): parent_inspector = tentacles_management.evaluator_parent_inspection if tentacle_type == TRADING_MODE_KEY: parent_inspector = tentacles_management.trading_mode_parent_inspection if tentacle_type in (EXCHANGE_KEY, WEB_PLUGIN_KEY): parent_inspector = tentacles_management.default_parents_inspection klass = tentacles_management.get_class_from_string(name, abstract_class, package, parent_inspector) if klass: if with_info: info = { DESCRIPTION_KEY: get_tentacle_documentation(name, media_url), NAME_KEY: name } if tentacle_type == TRADING_MODE_KEY: _add_trading_mode_requirements_and_default_config(info, klass) activation_states = _get_trading_tentacles_activation() elif tentacle_type == EXCHANGE_KEY: activation_states = _get_trading_tentacles_activation() elif tentacle_type == WEB_PLUGIN_KEY: activation_states = _get_services_tentacles_activation() else: activation_states = _get_evaluators_tentacles_activation() if tentacle_type == STRATEGY_KEY: _add_strategy_requirements_and_default_config(info, klass) info[constants.ACTIVATION_KEY] = _get_activation_state(name, activation_states) return klass, tentacle_type, info else: return klass, tentacle_type, None return None, None, None def get_tentacle_user_commands(klass): return klass.get_user_commands() if klass is not None and hasattr(klass, "get_user_commands") else {} async def get_tentacle_config_and_user_inputs(tentacle_class, bot_config, tentacles_setup_config): return await tentacle_class.get_raw_config_and_user_inputs( bot_config, tentacles_setup_config, interfaces_util.get_bot_api().get_bot_id() ) def get_tentacle_config_and_edit_display(tentacle, tentacle_class=None, profile_id=None): config = interfaces_util.get_edited_config() tentacles_setup_config = interfaces_util.get_edited_tentacles_config() if profile_id: config = models.get_profile(profile_id).config tentacles_setup_config = models.get_tentacles_setup_config_from_profile_id(profile_id) tentacle_class = tentacle_class or tentacles_manager_api.get_tentacle_class_from_string(tentacle) config, user_inputs = interfaces_util.run_in_bot_main_loop( get_tentacle_config_and_user_inputs(tentacle_class, config, tentacles_setup_config) ) display_elements = display.display_translator_factory() display_elements.add_user_inputs(user_inputs) return { NAME_KEY: tentacle, CONFIG_KEY: config or {}, DISPLAYED_ELEMENTS_KEY: display_elements.to_json() } def are_automations_enabled(): return octobot_constants.ENABLE_AUTOMATIONS def is_advanced_interface_enabled(): return octobot_constants.ENABLE_ADVANCED_INTERFACE def restart_global_automations(): interfaces_util.run_in_bot_main_loop( interfaces_util.get_bot_api().get_automation().restart(), log_exceptions=False ) def get_all_automation_steps(): return interfaces_util.get_bot_api().get_automation().get_all_steps() def has_at_least_one_running_automation(): return bool(get_automations_count()) def get_automations_count(): return len(interfaces_util.get_bot_api().get_automation().automation_details) def reset_automation_config_to_default(): try: interfaces_util.get_bot_api().get_automation().reset_config() return True, f"{interfaces_util.get_bot_api().get_automation().get_name()} configuration reset to default values" except Exception as err: return False, str(err) def get_tentacle_config(klass): return tentacles_manager_api.get_tentacle_config(interfaces_util.get_edited_tentacles_config(), klass) def get_cached_tentacle_config(klass): """ Should only be used to read static parts of a tentacle config (like requirements) """ key = klass if isinstance(klass, str) else klass.get_name() try: return _TENTACLE_CONFIG_CACHE[key] except KeyError: _TENTACLE_CONFIG_CACHE[key] = get_tentacle_config(klass) return _TENTACLE_CONFIG_CACHE[key] def get_tentacle_config_schema(klass): try: _get_logger().error("get_tentacle_config_schema") with open(tentacles_manager_api.get_tentacle_config_schema_path(klass)) as schema_file: return schema_file.read() except Exception: return "" def _get_tentacle_activation_desc(name, activated, startup_val, media_url, missing_tentacles: set): return { constants.TENTACLE_CLASS_NAME: name, constants.ACTIVATION_KEY: activated, DESCRIPTION_KEY: get_tentacle_documentation(name, media_url, missing_tentacles), constants.STARTUP_CONFIG_KEY: startup_val } def _add_tentacles_activation_desc_for_group(activation_by_group, tentacles_activation, startup_tentacles_activation, root_element, media_url, missing_tentacles: set): for tentacle_class_name, activated in tentacles_activation.get(root_element, {}).items(): startup_val = startup_tentacles_activation[root_element][tentacle_class_name] try: tentacle = _get_tentacle_activation_desc(tentacle_class_name, activated, startup_val, media_url, missing_tentacles) group = tentacles_manager_api.get_tentacle_group(tentacle_class_name) if group in activation_by_group: activation_by_group[group].append(tentacle) else: activation_by_group[group] = [tentacle] except AttributeError: # can happen when tentacles metadata.json are invalid pass def get_extra_tentacles_config_desc(media_url, missing_tentacles: set): tentacles_descriptions = [] all_tentacles = { tentacle_class.__name__: tentacle_class for tentacle_class in tentacles_management.AbstractTentacle.get_all_subclasses() } for tentacle_type in EXTRA_CONFIGURABLE_TENTACLES_TYPES: for tentacle_class_name in tentacles_manager_api.get_tentacles_classes_names_for_type(tentacle_type): if tentacle_class_name in all_tentacles and all_tentacles[tentacle_class_name].is_configurable(): try: tentacles_descriptions.append( _get_tentacle_activation_desc( tentacle_class_name, True, True, media_url, missing_tentacles ) ) except AttributeError: # can happen when tentacles metadata.json are invalid pass return tentacles_descriptions def get_tentacles_activation_desc_by_group(media_url, missing_tentacles: set): tentacles_activation = tentacles_manager_api.get_tentacles_activation(interfaces_util.get_edited_tentacles_config()) startup_tentacles_activation = tentacles_manager_api.get_tentacles_activation( interfaces_util.get_startup_tentacles_config()) activation_by_group = {} for root_element in GROUPPABLE_NON_TRADING_STRATEGY_RELATED_TENTACLES: try: _add_tentacles_activation_desc_for_group(activation_by_group, tentacles_activation, startup_tentacles_activation, root_element, media_url, missing_tentacles) except KeyError: pass # only return tentacle groups for which there is an activation choice to simplify the config interface return {group: tentacles for group, tentacles in activation_by_group.items() if len(tentacles) > 1} def update_tentacle_config(tentacle_name, config_update, tentacle_class=None, tentacles_setup_config=None): try: tentacle_class = tentacle_class or get_tentacle_from_string(tentacle_name, None, with_info=False)[0] if tentacle_class is None: return False, f"Can't find {tentacle_name} class" tentacles_manager_api.update_tentacle_config( tentacles_setup_config or interfaces_util.get_edited_tentacles_config(), tentacle_class, config_update ) return True, f"{tentacle_name} updated" except Exception as e: _get_logger().exception(e, False) return False, f"Error when updating tentacle config: {e}" def update_copied_trading_id(copy_id): import tentacles.Trading.Mode as modes return update_tentacle_config( modes.RemoteTradingSignalsTradingMode.get_name(), { "trading_strategy": copy_id } ) def reset_config_to_default(tentacle_name, tentacle_class=None, tentacles_setup_config=None): try: tentacle_class = tentacle_class or get_tentacle_from_string(tentacle_name, None, with_info=False)[0] tentacles_manager_api.factory_tentacle_reset_config( tentacles_setup_config or interfaces_util.get_edited_tentacles_config(), tentacle_class ) return True, f"{tentacle_name} configuration reset to default values" except FileNotFoundError as e: error_message = f"Error when resetting factory tentacle config: no default values file at {e.filename}" _get_logger().error(error_message) return False, error_message except Exception as e: _get_logger().exception(e, False) return False, f"Error when resetting factory tentacle config: {e}" def _get_required_element(elements_config): requirements = REQUIREMENTS_KEY required_elements = set() for element_type in elements_config.values(): for element_name, element in element_type.items(): if element[constants.ACTIVATION_KEY]: if requirements in element: required_elements = required_elements.union(element[requirements]) return required_elements def _add_strategy_requirements_and_default_config(desc, klass): tentacles_config = interfaces_util.get_startup_tentacles_config() strategy_config = get_cached_tentacle_config(klass) desc[REQUIREMENTS_KEY] = [evaluator for evaluator in klass.get_required_evaluators(tentacles_config, strategy_config)] desc[COMPATIBLE_TYPES_KEY] = [evaluator for evaluator in klass.get_compatible_evaluators_types(tentacles_config, strategy_config)] desc[DEFAULT_CONFIG_KEY] = [evaluator for evaluator in klass.get_default_evaluators(tentacles_config, strategy_config)] def _add_trading_mode_requirements_and_default_config(desc, klass): tentacles_config = interfaces_util.get_startup_tentacles_config() mode_config = get_cached_tentacle_config(klass) required_strategies, required_strategies_count = klass.get_required_strategies_names_and_count(tentacles_config, mode_config) if required_strategies: desc[REQUIREMENTS_KEY] = \ [strategy for strategy in required_strategies] desc[DEFAULT_CONFIG_KEY] = \ [strategy for strategy in klass.get_default_strategies(tentacles_config, mode_config)] desc[REQUIREMENTS_COUNT_KEY] = required_strategies_count else: desc[REQUIREMENTS_KEY] = [] desc[REQUIREMENTS_COUNT_KEY] = 0 def _add_strategies_requirements(strategies, strategy_config): required_elements = _get_required_element(strategy_config) for classKey, klass in strategies.items(): _add_strategy_requirements_and_default_config(strategy_config[STRATEGIES_KEY][classKey], klass) strategy_config[STRATEGIES_KEY][classKey][REQUIRED_KEY] = classKey in required_elements def _add_trading_modes_requirements(trading_modes_list, strategy_config): for classKey, klass in trading_modes_list.items(): try: _add_trading_mode_requirements_and_default_config(strategy_config[TRADING_MODES_KEY][classKey], klass) except Exception as e: _get_logger().exception(e, False) def get_strategy_config( media_url, missing_tentacles: set, with_trading_modes=True, whitelist=None, backtestable_only=False ): strategy_config, strategy_config_classes = _get_strategy_activation_state(with_trading_modes, media_url, missing_tentacles, whitelist=whitelist, backtestable_only=backtestable_only) if with_trading_modes: _add_trading_modes_requirements(strategy_config_classes[TRADING_MODES_KEY], strategy_config) _add_strategies_requirements(strategy_config_classes[STRATEGIES_KEY], strategy_config) return strategy_config def get_in_backtesting_mode(): return backtesting_api.is_backtesting_enabled(interfaces_util.get_global_config()) def accepted_terms(): return interfaces_util.get_edited_config(dict_only=False).accepted_terms() def accept_terms(accepted): return interfaces_util.get_edited_config(dict_only=False).accept_terms(accepted) def _fill_evaluator_config(evaluator_name, activated, eval_type_key, evaluator_type, detailed_config, media_url, name_filter=None): klass = tentacles_management.get_class_from_string(evaluator_name, evaluators.AbstractEvaluator, evaluator_type, tentacles_management.evaluator_parent_inspection) filtered = name_filter and evaluator_name != name_filter if klass: if not filtered: detailed_config[eval_type_key][evaluator_name] = {} detailed_config[eval_type_key][evaluator_name][constants.ACTIVATION_KEY] = activated detailed_config[eval_type_key][evaluator_name][DESCRIPTION_KEY] = \ get_tentacle_documentation(evaluator_name, media_url) detailed_config[eval_type_key][evaluator_name][EVALUATION_FORMAT_KEY] = "float" \ if klass.get_eval_type() == evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE \ else str(klass.get_eval_type()) return True, klass, filtered return False, klass, filtered def get_evaluator_detailed_config(media_url, missing_tentacles: set, single_strategy=None): import tentacles.Evaluator.Strategies as strategies import tentacles.Evaluator.TA as ta import tentacles.Evaluator.Social as social import tentacles.Evaluator.RealTime as rt import tentacles.Evaluator.Scripted as scripted detailed_config = { SOCIAL_KEY: {}, TA_KEY: {}, RT_KEY: {}, SCRIPTED_KEY: {} } strategy_config = { STRATEGIES_KEY: {} } strategy_class_by_name = {} evaluator_config = _get_evaluators_tentacles_activation() for evaluator_name, activated in evaluator_config.items(): is_TA, klass, _ = _fill_evaluator_config(evaluator_name, activated, TA_KEY, ta, detailed_config, media_url) if not is_TA: is_social, klass, _ = _fill_evaluator_config(evaluator_name, activated, SOCIAL_KEY, social, detailed_config, media_url) if not is_social: is_real_time, klass, _ = _fill_evaluator_config(evaluator_name, activated, RT_KEY, rt, detailed_config, media_url) if not is_real_time: is_scripted, klass, _ = _fill_evaluator_config(evaluator_name, activated, SCRIPTED_KEY, scripted, detailed_config, media_url) if not is_scripted: is_strategy, klass, filtered = _fill_evaluator_config(evaluator_name, activated, STRATEGIES_KEY, strategies, strategy_config, media_url, name_filter=single_strategy) if is_strategy: if not filtered: strategy_class_by_name[evaluator_name] = klass else: _add_to_missing_tentacles_if_missing(evaluator_name, missing_tentacles) _add_strategies_requirements(strategy_class_by_name, strategy_config) if required_elements := _get_required_element(strategy_config): for eval_type in detailed_config.values(): for eval_name, eval_details in eval_type.items(): eval_details[REQUIRED_KEY] = eval_name in required_elements detailed_config[ACTIVATED_STRATEGIES] = [ s for s, details in strategy_config[STRATEGIES_KEY].items() if details[constants.ACTIVATION_KEY] ] return detailed_config def get_config_activated_trading_mode(tentacles_setup_config=None): try: return trading_api.get_activated_trading_mode( tentacles_setup_config or interfaces_util.get_bot_api().get_edited_tentacles_config() ) except commons_errors.ConfigTradingError: return None def get_config_activated_strategies(tentacles_setup_config=None): return evaluators_api.get_activated_strategies_classes( tentacles_setup_config or interfaces_util.get_bot_api().get_edited_tentacles_config() ) def get_config_activated_evaluators(tentacles_setup_config=None): return evaluators_api.get_activated_evaluators( tentacles_setup_config or interfaces_util.get_bot_api().get_edited_tentacles_config() ) def has_futures_exchange(): for exchange_manager in get_live_trading_enabled_exchange_managers(): if trading_api.get_exchange_type(exchange_manager) is trading_enums.ExchangeTypes.FUTURE: return True return False def update_tentacles_activation_config(new_config, deactivate_others=False, tentacles_setup_configuration=None): tentacles_setup_configuration = tentacles_setup_configuration or interfaces_util.get_edited_tentacles_config() try: updated_config = { element_name: activated if isinstance(activated, bool) else activated.lower() == "true" for element_name, activated in new_config.items() } if tentacles_manager_api.update_activation_configuration( tentacles_setup_configuration, updated_config, deactivate_others ): tentacles_manager_api.save_tentacles_setup_configuration(tentacles_setup_configuration) return True except Exception as e: _get_logger().exception(e, True, f"Error when updating tentacles activation {e}") return False def get_active_exchanges(): return trading_api.get_enabled_exchanges_names(interfaces_util.get_startup_config(dict_only=True)) async def _reset_profile_portfolio_history(current_edited_config): models.clear_exchanges_portfolio_history(simulated_only=True) if not trading_api.is_trader_simulator_enabled_in_config(current_edited_config.config): return # also reset portfolio history for exchanges enabled in config that are not enabled in the current instance already_reset_exchanges = { trading_api.get_exchange_name(exchange_manager): exchange_manager for exchange_manager in trading_api.get_exchange_managers_from_exchange_ids(trading_api.get_exchange_ids()) } run_dbs_identifier = octobot_databases_util.get_run_databases_identifier( current_edited_config.config, interfaces_util.get_edited_tentacles_config() ) enabled_exchanges = trading_api.get_enabled_exchanges_names(current_edited_config.config) _get_logger().info(f"Resetting simulated portfolio history for {enabled_exchanges}.") for exchange in enabled_exchanges: for is_future in (True, False): # force reset future and non future historical portfolio if exchange not in already_reset_exchanges \ or ((trading_api.get_exchange_type(already_reset_exchanges[exchange]) == trading_enums.ExchangeTypes.FUTURE) != is_future): metadb = commons_databases.MetaDatabase(run_dbs_identifier) portfolio_db = metadb.get_historical_portfolio_value_db( trading_api.get_account_type(is_future, False, False, True), exchange ) await trading_api.clear_database_storage_history( trading_storage.PortfolioStorage, portfolio_db, False ) await metadb.close() def _handle_special_fields(current_edited_config, new_config): config = current_edited_config.config try: # replace web interface password by its hash before storage web_password_key = constants.UPDATED_CONFIG_SEPARATOR.join([services_constants.CONFIG_CATEGORY_SERVICES, services_constants.CONFIG_WEB, services_constants.CONFIG_WEB_PASSWORD]) if web_password_key in new_config: new_config[web_password_key] = configuration.get_password_hash(new_config[web_password_key]) # add exchange enabled param if missing for key in list(new_config.keys()): values = key.split(constants.UPDATED_CONFIG_SEPARATOR) if values[0] == commons_constants.CONFIG_EXCHANGES and \ values[1] not in config[commons_constants.CONFIG_EXCHANGES]: enabled_key = constants.UPDATED_CONFIG_SEPARATOR.join([commons_constants.CONFIG_EXCHANGES, values[1], commons_constants.CONFIG_ENABLED_OPTION]) if enabled_key not in new_config: new_config[enabled_key] = True except KeyError: pass def _handle_simulated_portfolio(current_edited_config, new_config): # reset portfolio history if simulated portfolio has changed if any( f"{commons_constants.CONFIG_SIMULATOR}{constants.UPDATED_CONFIG_SEPARATOR}" \ f"{commons_constants.CONFIG_STARTING_PORTFOLIO}" in key for key in new_config ): try: interfaces_util.run_in_bot_async_executor( _reset_profile_portfolio_history(current_edited_config) ) except Exception as err: _get_logger().exception(err, True, f"Error when resetting portfolio simulator history {err}") def update_global_config(new_config, delete=False): try: current_edited_config = interfaces_util.get_edited_config(dict_only=False) if not delete: _handle_special_fields(current_edited_config, new_config) current_edited_config.update_config_fields(new_config, backtesting_api.is_backtesting_enabled(current_edited_config.config), constants.UPDATED_CONFIG_SEPARATOR, delete=delete) _handle_simulated_portfolio(current_edited_config, new_config) return True, "" except Exception as e: _get_logger().exception(e, True, f"Error when updating global config {e}") return False, str(e) def activate_metrics(enable_metrics): current_edited_config = interfaces_util.get_edited_config(dict_only=False) if commons_constants.CONFIG_METRICS not in current_edited_config.config: current_edited_config.config[commons_constants.CONFIG_METRICS] = { commons_constants.CONFIG_ENABLED_OPTION: enable_metrics} else: current_edited_config.config[commons_constants.CONFIG_METRICS][ commons_constants.CONFIG_ENABLED_OPTION] = enable_metrics if enable_metrics and community.CommunityManager.should_register_bot(current_edited_config): community.CommunityManager.background_get_id_and_register_bot(interfaces_util.get_bot_api()) current_edited_config.save() def activate_beta_env(enable_beta): new_env = octobot_enums.CommunityEnvironments.Staging if enable_beta \ else octobot_enums.CommunityEnvironments.Production current_edited_config = interfaces_util.get_edited_config(dict_only=False) if octobot_constants.CONFIG_COMMUNITY not in current_edited_config.config: current_edited_config.config[octobot_constants.CONFIG_COMMUNITY] = {} current_edited_config.config[octobot_constants.CONFIG_COMMUNITY][ octobot_constants.CONFIG_COMMUNITY_ENVIRONMENT] = new_env.value current_edited_config.save() def get_metrics_enabled(): return interfaces_util.get_edited_config(dict_only=False).get_metrics_enabled() def get_beta_env_enabled_in_config(): return octobot_constants.USE_BETA_EARLY_ACCESS or community.IdentifiersProvider.is_staging_environment_enabled( interfaces_util.get_edited_config(dict_only=True) ) def get_services_list(): services = {} for service in services_api.get_available_services(): srv = service.instance() if srv.get_required_config(): # do not add services without a config, ex: GoogleService (nothing to show on the web interface) services[srv.get_type()] = srv return services def get_notifiers_list(): return [service.instance().get_type() for notifier in services_api.create_notifier_factory({}).get_available_notifiers() for service in notifier.REQUIRED_SERVICES] def get_enabled_trading_pairs() -> set: symbols = set() for values in format_config_symbols(interfaces_util.get_edited_config()).values(): if values[commons_constants.CONFIG_ENABLED_OPTION]: symbols = symbols.union(set(values[commons_constants.CONFIG_CRYPTO_PAIRS])) return symbols def get_exchange_available_trading_pairs(exchange_manager, profile=None) -> list: return trading_api.get_trading_pairs(exchange_manager) if profile is None else [ pair for pair in trading_api.get_all_exchange_symbols(exchange_manager) if pair in trading_api.get_config_symbols(profile.config, True) ] def get_symbol_list(exchanges): result = interfaces_util.run_in_bot_async_executor(_load_markets(exchanges)) return list(set(result)) def get_all_currencies(exchanges): symbols = [ commons_symbols.parse_symbol(symbol) for symbol in get_symbol_list(exchanges) ] return list( set(symbol.base for symbol in symbols).union(set(symbol.quote for symbol in symbols)) ) def _get_filtered_exchange_symbols(symbols): return [res for res in symbols if octobot_commons.MARKET_SEPARATOR in res] async def _load_market(exchange, results): try: if exchange in auto_filled_exchanges(): async with trading_api.get_new_ccxt_client( exchange, {}, interfaces_util.get_edited_tentacles_config(), False ) as client: await client.load_markets() symbols = client.symbols else: async with getattr(ccxt.async_support, exchange)({'verbose': False}) as client: client.logger.setLevel(logging.INFO) # prevent log of each request (huge on market statuses) await client.load_markets() symbols = client.symbols # filter symbols with a "." or no "/" because bot can't handle them for now markets_by_exchanges[exchange] = _get_filtered_exchange_symbols(symbols) results.append(markets_by_exchanges[exchange]) except Exception as e: _get_logger().exception(e, True, f"error when loading symbol list for {exchange}: {e}") def _add_merged_exchanges(exchanges): extended = list(exchanges) for exchange in exchanges: if exchange in MERGED_CCXT_EXCHANGES: for merged_exchange in MERGED_CCXT_EXCHANGES[exchange]: extended.append(merged_exchange) return extended async def _load_markets(exchanges): result = [] results = [] fetch_coros = [] exchange_managers = trading_api.get_exchange_managers_from_exchange_ids( trading_api.get_exchange_ids() ) exchange_manager_by_exchange_name = { trading_api.get_exchange_name(exchange_manager): exchange_manager for exchange_manager in exchange_managers if not trading_api.get_is_backtesting(exchange_manager) } for exchange in _add_merged_exchanges(exchanges): if exchange not in exchange_symbol_fetch_blacklist: if exchange in exchange_manager_by_exchange_name and exchange not in markets_by_exchanges: markets_by_exchanges[exchange] = _get_filtered_exchange_symbols( trading_api.get_all_exchange_symbols(exchange_manager_by_exchange_name[exchange]) ) if exchange in markets_by_exchanges: result += markets_by_exchanges[exchange] else: fetch_coros.append(_load_market(exchange, results)) if fetch_coros: await asyncio.gather(*fetch_coros) for res in results: result += res return result def get_config_time_frames() -> list: return time_frame_manager.get_config_time_frame(interfaces_util.get_global_config()) def get_timeframes_list(exchanges): timeframes_list = [] allowed_timeframes = set(tf.value for tf in commons_enums.TimeFrames) for exchange in exchanges: if exchange not in exchange_symbol_fetch_blacklist: timeframes_list += interfaces_util.run_in_bot_async_executor( trading_api.get_ccxt_exchange_available_time_frames( exchange, interfaces_util.get_edited_tentacles_config() )) return [commons_enums.TimeFrames(time_frame) for time_frame in list(set(timeframes_list)) if time_frame in allowed_timeframes] def get_strategy_required_time_frames(strategy_class, tentacles_setup_config=None): return strategy_class.get_required_time_frames( {}, tentacles_setup_config or interfaces_util.get_edited_tentacles_config() ) def format_config_symbols(config): for currency, data in config[commons_constants.CONFIG_CRYPTO_CURRENCIES].items(): if commons_constants.CONFIG_ENABLED_OPTION not in data: config[commons_constants.CONFIG_CRYPTO_CURRENCIES][currency] = \ {**{commons_constants.CONFIG_ENABLED_OPTION: True}, **data} return config[commons_constants.CONFIG_CRYPTO_CURRENCIES] def format_config_symbols_without_enabled_key(config): enabled_config = {} for currency, data in config[commons_constants.CONFIG_CRYPTO_CURRENCIES].items(): if data.get(commons_constants.CONFIG_ENABLED_OPTION, False) and data[commons_constants.CONFIG_CRYPTO_PAIRS]: enabled_config[currency] = { commons_constants.CONFIG_CRYPTO_PAIRS: data[commons_constants.CONFIG_CRYPTO_PAIRS] } return enabled_config def _is_legit_currency(currency): return not any(sub_name in currency for sub_name in EXCLUDED_CURRENCY_SUBNAME) and len(currency) < 30 def get_all_symbols_list(): import tentacles.Services.Interfaces.web_interface.flask_util as flask_util data_provider = flask_util.BrowsingDataProvider.instance() all_currencies = copy.copy(data_provider.get_all_currencies()) if not all_currencies: added_is = set() request_response = None base_error = "Failed to get currencies list from coingecko.com (this is a display only issue): " try: # inspired from https://github.com/man-c/pycoingecko session = requests.Session() retries = urllib3.util.retry.Retry(total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504]) session.mount('http://', requests.adapters.HTTPAdapter(max_retries=retries)) # first fetch top 250 currencies then add all currencies and their ids for url in (f"{constants.CURRENCIES_LIST_URL}1", constants.ALL_SYMBOLS_URL): request_response = session.get(url) if request_response.status_code == 429: # rate limit issue _get_logger().warning(f"{base_error}Too many requests, retry in a few seconds") break for currency_data in request_response.json(): if _is_legit_currency(currency_data[NAME_KEY]): currency_id = currency_data["id"] if currency_id not in added_is: added_is.add(currency_id) all_currencies.append(_get_currency_dict( currency_data[NAME_KEY], currency_data["symbol"], currency_id )) # fetched_all: save it data_provider.set_all_currencies(all_currencies) except Exception as e: str_error = html_util.get_html_summary_if_relevant(e) details = f"code: {request_response.status_code}, error: {str_error}" \ if request_response else {request_response} _get_logger().exception(e, True, f"{base_error}{str_error}") _get_logger().debug(f"coingecko.com response {details}") return {} return all_currencies def get_all_symbols_list_by_symbol_type(all_symbols, config_symbols): spot = "SPOT trading" linear = "Futures trading - linear" inverse = "Futures trading - inverse" def _is_of_type(symbol, trading_type): parsed = commons_symbols.parse_symbol(symbol) if parsed.is_spot(): return trading_type == spot elif parsed.is_perpetual_future(): if trading_type == linear: return parsed.is_linear() if trading_type == inverse: return parsed.is_inverse() return False symbols_by_type = { trading_type: [symbol for symbol in all_symbols if _is_of_type(symbol, trading_type)] for trading_type in ( spot, linear, inverse ) } symbols_in_config = set().union(*( set(currency_details.get('pairs', [])) for currency_details in config_symbols.values() )) if symbols_in_config: listed_symbols = set().union(*(set(symbols) for symbols in symbols_by_type.values())) missing_symbols = symbols_in_config - listed_symbols if missing_symbols: symbols_by_type["Configured (missing on enabled exchanges)"] = list(missing_symbols) return symbols_by_type def get_exchange_logo(exchange_name): try: return exchange_logos[exchange_name] except KeyError: try: exchange_logos[exchange_name] = {"image": "", "url": ""} if isinstance(exchange_name, str) and exchange_name != "Bitcoin": exchange_details = interfaces_util.run_in_bot_main_loop( trading_api.get_exchange_details( exchange_name, exchange_name in auto_filled_exchanges(), interfaces_util.get_edited_tentacles_config(), interfaces_util.get_bot_api().get_aiohttp_session() ) ) exchange_logos[exchange_name]["image"] = exchange_details.logo_url exchange_logos[exchange_name]["url"] = exchange_details.url except KeyError: pass return exchange_logos[exchange_name] def _get_currency_logo_url(currency_id): return f"https://api.coingecko.com/api/v3/coins/{currency_id}?localization=false&tickers=false&market_data=" \ f"false&community_data=false&developer_data=false&sparkline=false" async def _fetch_currency_logo(session, data_provider, currency_id): if not currency_id: return async with session.get(_get_currency_logo_url(currency_id)) as resp: logo = None try: json_resp = await resp.json() logo = json_resp["image"]["large"] except KeyError: if resp.status == 429: _get_logger().debug(f"Rate limitted when trying to fetch logo for {currency_id}. Will retry later") else: # not rate limit: problem _get_logger().warning(f"Unexpected error when fetching {currency_id} currency logos: " f"status: {resp.status} text: {await resp.text()}") # can't fetch image for some reason, use default data_provider.set_currency_logo_url(currency_id, logo, dump=False) async def _fetch_missing_currency_logos(data_provider, currency_ids): # always use certify_aiohttp_client_session to avoid triggering rate limit with test request async with aiohttp_util.certify_aiohttp_client_session() as session: await asyncio.gather( *( _fetch_currency_logo(session, data_provider, currency_id) for currency_id in currency_ids if data_provider.get_currency_logo_url(currency_id) is None ) ) data_provider.dump_saved_data() def get_currency_logo_urls(currency_ids): import tentacles.Services.Interfaces.web_interface.flask_util as flask_util data_provider = flask_util.BrowsingDataProvider.instance() if any( data_provider.get_currency_logo_url(currency_id) is None for currency_id in currency_ids ): interfaces_util.run_in_bot_async_executor(_fetch_missing_currency_logos(data_provider, currency_ids)) return [ { "id": currency_id, "logo": data_provider.get_currency_logo_url(currency_id) } for currency_id in currency_ids ] def get_traded_time_frames(exchange_manager, strategies=None, tentacles_setup_config=None) -> list: if strategies is None: return trading_api.get_relevant_time_frames(exchange_manager) strategies_time_frames = [] for strategy_class in strategies: strategies_time_frames += [ tf.value for tf in get_strategy_required_time_frames(strategy_class, tentacles_setup_config) ] return [ commons_enums.TimeFrames(time_frame) for time_frame in trading_api.get_all_exchange_time_frames(exchange_manager) if time_frame in strategies_time_frames ] def get_or_init_FULL_EXCHANGE_LIST(): global _FULL_EXCHANGE_LIST if _FULL_EXCHANGE_LIST is None: _FULL_EXCHANGE_LIST = [ exchange for exchange in set(ccxt.async_support.exchanges) if exchange not in REMOVED_CCXT_EXCHANGES ] return _FULL_EXCHANGE_LIST def auto_filled_exchanges(tentacles_setup_config=None): global AUTO_FILLED_EXCHANGES if AUTO_FILLED_EXCHANGES is None: tentacles_setup_config = tentacles_setup_config or interfaces_util.get_edited_tentacles_config() full_exchange_list = get_or_init_FULL_EXCHANGE_LIST() AUTO_FILLED_EXCHANGES = [ exchange_name for exchange_name in trading_api.get_auto_filled_exchange_names(tentacles_setup_config) if exchange_name not in full_exchange_list ] full_exchange_list.extend(AUTO_FILLED_EXCHANGES) return AUTO_FILLED_EXCHANGES def get_full_exchange_list(tentacles_setup_config=None): auto_filled_exchanges(tentacles_setup_config) return get_or_init_FULL_EXCHANGE_LIST() def get_full_configurable_exchange_list(remove_config_exchanges=False): g_config = interfaces_util.get_global_config() full_exchange_list = get_or_init_FULL_EXCHANGE_LIST() if remove_config_exchanges: user_exchanges = [e for e in g_config[commons_constants.CONFIG_EXCHANGES]] full_exchange_list = list(set(full_exchange_list) - set(user_exchanges)) else: full_exchange_list = full_exchange_list # can't handle exchanges containing UPDATED_CONFIG_SEPARATOR character in their name return [ exchange for exchange in full_exchange_list if constants.UPDATED_CONFIG_SEPARATOR not in exchange ] def get_default_exchange(): return ccxt.async_support.binance.__name__ def get_tested_exchange_list(): return [ exchange for exchange in trading_constants.TESTED_EXCHANGES if exchange in get_full_exchange_list() ] def get_simulated_exchange_list(): return [ exchange for exchange in trading_constants.SIMULATOR_TESTED_EXCHANGES if exchange in get_full_exchange_list() ] def get_other_exchange_list(remove_config_exchanges=False): full_list = get_full_configurable_exchange_list(remove_config_exchanges) return [ exchange for exchange in full_list if exchange not in trading_constants.TESTED_EXCHANGES and exchange not in trading_constants.SIMULATOR_TESTED_EXCHANGES ] def get_enabled_exchange_types(config_exchanges): return { config.get(commons_constants.CONFIG_EXCHANGE_TYPE, trading_enums.ExchangeTypes.SPOT.value) for config in config_exchanges.values() if config.get(commons_constants.CONFIG_ENABLED_OPTION, True) } def get_exchanges_details(exchanges_config) -> dict: details = {} tentacles_setup_config = interfaces_util.get_edited_tentacles_config() import tentacles.Trading.Exchange as exchanges for exchange_name in exchanges_config: exchange_class = tentacles_management.get_class_from_string( exchange_name, trading_exchanges.AbstractExchange, exchanges, tentacles_management.default_parents_inspection ) details[exchange_name] = { "has_websockets": trading_api.supports_websockets(exchange_name, tentacles_setup_config), "configurable": False if exchange_class is None else exchange_class.is_configurable(), "supported_exchange_types": trading_api.get_supported_exchange_types( exchange_name, tentacles_setup_config ), "default_exchange_type": trading_api.get_default_exchange_type(exchange_name), } return details def get_compatibility_result(exchange_name, auth_success, compatible_account, supporter_account, configured_account, supporting_exchange, error_message, exchange_type): return { "exchange": exchange_name, "auth_success": auth_success, "compatible_account": compatible_account, "supporter_account": supporter_account, "configured_account": configured_account, "supporting_exchange": supporting_exchange, "exchange_type": exchange_type, "error_message": error_message } async def _check_account_with_other_exchange_type_if_possible( exchange_name: str, checked_config: dict, tentacles_setup_config, is_sandboxed: bool, supported_types: list ): is_compatible = False auth_success = False error = "" ignored_type = checked_config.get(commons_constants.CONFIG_EXCHANGE_TYPE, commons_constants.DEFAULT_EXCHANGE_TYPE) for supported_type in supported_types: if supported_type.value == ignored_type: continue checked_config[commons_constants.CONFIG_EXCHANGE_TYPE] = supported_type.value is_compatible, auth_success, error = await trading_api.is_compatible_account( exchange_name, checked_config, tentacles_setup_config, checked_config.get(commons_constants.CONFIG_EXCHANGE_SANDBOXED, False) ) if auth_success: return is_compatible, auth_success, error # failed auth return is_compatible, auth_success, error, async def _fetch_is_compatible_account(exchange_name, to_check_config, compatibility_results, is_sponsoring, is_supporter): try: checked_config = copy.deepcopy(to_check_config) tentacles_setup_config = interfaces_util.get_edited_tentacles_config() is_compatible, auth_success, error = await trading_api.is_compatible_account( exchange_name, checked_config, tentacles_setup_config, checked_config.get(commons_constants.CONFIG_EXCHANGE_SANDBOXED, False) ) if not auth_success: supported_types = trading_api.get_supported_exchange_types(exchange_name, tentacles_setup_config) if len(supported_types) > 1: is_compatible, auth_success, error = await _check_account_with_other_exchange_type_if_possible( exchange_name, checked_config, interfaces_util.get_edited_tentacles_config(), checked_config.get(commons_constants.CONFIG_EXCHANGE_SANDBOXED, False), supported_types ) compatibility_results[exchange_name] = get_compatibility_result( exchange_name, auth_success, is_compatible, is_supporter, True, is_sponsoring, error, checked_config.get(commons_constants.CONFIG_EXCHANGE_TYPE, commons_constants.DEFAULT_EXCHANGE_TYPE) ) except Exception as err: bot_logging.get_logger("ConfigurationWebInterfaceModel").exception( err, True, f"Error when checking {exchange_name} exchange credentials: {err}" ) def are_compatible_accounts(exchange_details: dict) -> dict: compatibility_results = {} check_coro = [] for exchange, exchange_detail in exchange_details.items(): exchange_name = exchange_detail["exchange"] api_key = exchange_detail["apiKey"] api_sec = exchange_detail["apiSecret"] api_pass = exchange_detail["apiPassword"] sandboxed = exchange_detail[commons_constants.CONFIG_EXCHANGE_SANDBOXED] to_check_config = copy.deepcopy(interfaces_util.get_edited_config()[commons_constants.CONFIG_EXCHANGES].get( exchange_name, {})) if _is_real_exchange_value(api_key): to_check_config[commons_constants.CONFIG_EXCHANGE_KEY] = configuration.encrypt(api_key).decode() if _is_real_exchange_value(api_sec): to_check_config[commons_constants.CONFIG_EXCHANGE_SECRET] = configuration.encrypt(api_sec).decode() if _is_real_exchange_value(api_pass): to_check_config[commons_constants.CONFIG_EXCHANGE_PASSWORD] = configuration.encrypt(api_pass).decode() to_check_config[commons_constants.CONFIG_EXCHANGE_SANDBOXED] = sandboxed is_compatible = auth_success = is_configured = False is_sponsoring = trading_api.is_sponsoring(exchange_name) is_supporter = authentication.Authenticator.instance().user_account.supports.is_supporting() error = None if _is_possible_exchange_config(to_check_config): check_coro.append(_fetch_is_compatible_account(exchange_name, to_check_config, compatibility_results, is_sponsoring, is_supporter)) else: compatibility_results[exchange_name] = get_compatibility_result( exchange_name, auth_success, is_compatible, is_supporter, is_configured, is_sponsoring, error, to_check_config.get( commons_constants.CONFIG_EXCHANGE_TYPE, commons_constants.DEFAULT_EXCHANGE_TYPE ) ) if check_coro: async def gather_wrapper(coros): await asyncio.gather(*coros) interfaces_util.run_in_bot_async_executor( gather_wrapper(check_coro) ) # trigger garbage collector as ccxt exchange can be heavy in RAM (20MB+) gc.collect() return compatibility_results def _is_possible_exchange_config(exchange_config): valid_count = 0 for key, value in exchange_config.items(): if key in commons_constants.CONFIG_EXCHANGE_ENCRYPTED_VALUES and _is_real_exchange_value(value): valid_count += 1 # require at least 2 data to consider a configuration possible return valid_count >= 2 def _is_real_exchange_value(value): placeholder_key = "******" if placeholder_key in value: return False return value not in commons_constants.DEFAULT_CONFIG_VALUES def get_current_exchange(): for exchange_manager in interfaces_util.get_exchange_managers(): return trading_api.get_exchange_name(exchange_manager) else: return DEFAULT_EXCHANGE def get_sandbox_exchanges() -> list: return [ trading_api.get_exchange_name(exchange_manager) for exchange_manager in interfaces_util.get_exchange_managers() if trading_api.get_exchange_manager_is_sandboxed(exchange_manager) ] def get_distribution() -> octobot_enums.OctoBotDistribution: return configuration_manager.get_distribution(interfaces_util.get_edited_config()) def change_reference_market_on_config_currencies(old_base_currency: str, new_quote_currency: str) -> bool: """ Change the base currency from old to new for all configured pair :return: bool, str """ success = True message = "Reference market changed for each pair using the old reference market" try: config_currencies = format_config_symbols(interfaces_util.get_edited_config()) for currencies_config in config_currencies.values(): currencies_config[commons_constants.CONFIG_CRYPTO_PAIRS] = \ list(set([ _change_base(pair, new_quote_currency) for pair in currencies_config[commons_constants.CONFIG_CRYPTO_PAIRS] ])) interfaces_util.get_edited_config(dict_only=False).save() except Exception as e: message = f"Error while changing reference market on currencies list: {e}" success = False bot_logging.get_logger("ConfigurationWebInterfaceModel").exception(e, False) return success, message def _change_base(pair, new_quote_currency): parsed_symbol = commons_symbols.parse_symbol(pair) parsed_symbol.quote = new_quote_currency return parsed_symbol.merged_str_symbol() def send_command_to_activated_tentacles(command, wait_for_processing=True): trading_mode_name = get_config_activated_trading_mode().get_name() evaluator_names = [ evaluator.get_name() for evaluator in get_config_activated_evaluators() ] send_command_to_tentacles(command, [trading_mode_name] + evaluator_names, wait_for_processing=wait_for_processing) def send_command_to_tentacles(command, tentacle_names: list, wait_for_processing=True): for tentacle_name in tentacle_names: interfaces_util.run_in_bot_main_loop( services_api.send_user_command( interfaces_util.get_bot_api().get_bot_id(), tentacle_name, command, None, wait_for_processing=wait_for_processing ) ) def reload_scripts(): try: send_command_to_activated_tentacles(commons_enums.UserCommands.RELOAD_SCRIPT.value) return {"success": True} except Exception as e: _get_logger().exception(e, True, f"Failed to reload scripts: {e}") raise def reload_activated_tentacles_config(): try: send_command_to_activated_tentacles(commons_enums.UserCommands.RELOAD_CONFIG.value) return {"success": True} except Exception as e: _get_logger().exception(e, True, f"Failed to reload configurations: {e}") raise def reload_tentacle_config(tentacle_name): try: send_command_to_tentacles(commons_enums.UserCommands.RELOAD_CONFIG.value, [tentacle_name]) return {"success": True} except Exception as e: _get_logger().exception(e, True, f"Failed to reload {tentacle_name} configuration: {e}") raise def update_config_currencies(currencies: dict, replace: bool=False): """ Update the configured currencies dict :param currencies: currencies dict :param replace: replace the current list :return: bool, str """ success = True message = "Currencies list updated" try: config_currencies = interfaces_util.get_edited_config()[commons_constants.CONFIG_CRYPTO_CURRENCIES] # prevent format issues checked_currencies = { currency: { commons_constants.CONFIG_CRYPTO_PAIRS: values[commons_constants.CONFIG_CRYPTO_PAIRS], commons_constants.CONFIG_ENABLED_OPTION: values.get(commons_constants.CONFIG_ENABLED_OPTION, True) } for currency, values in currencies.items() if ( isinstance(values.get(commons_constants.CONFIG_ENABLED_OPTION, True), bool) and commons_constants.CONFIG_CRYPTO_PAIRS in values and isinstance(values[commons_constants.CONFIG_CRYPTO_PAIRS], list) and all(isinstance(pair, str) for pair in commons_constants.CONFIG_CRYPTO_PAIRS) ) } interfaces_util.get_edited_config()[commons_constants.CONFIG_CRYPTO_CURRENCIES] = ( checked_currencies if replace else configuration.merge_dictionaries_by_appending_keys( config_currencies, checked_currencies, merge_sub_array=True ) ) interfaces_util.get_edited_config(dict_only=False).save() except Exception as e: message = f"Error while updating currencies list: {e}" success = False bot_logging.get_logger("ConfigurationWebInterfaceModel").exception(e, False) return success, message def get_config_required_candles_count(exchange_manager): return trading_api.get_required_historical_candles_count(exchange_manager) def get_live_trading_enabled_exchange_managers(): return [ exchange_manager for exchange_manager in trading_api.get_exchange_managers_from_exchange_ids(trading_api.get_exchange_ids()) if not trading_api.get_is_backtesting(exchange_manager) and trading_api.is_trader_existing_and_enabled(exchange_manager) ] ================================================ FILE: Services/Interfaces/web_interface/models/dashboard.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import numpy as np import math import octobot_backtesting.api as backtesting_api import octobot_services.interfaces.util as interfaces_util import octobot_trading.api as trading_api import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import tentacles.Services.Interfaces.web_interface.models.interface_settings as interface_settings import tentacles.Services.Interfaces.web_interface.enums as enums import octobot_commons.timestamp_util as timestamp_util import octobot_commons.enums as commons_enums import octobot_commons.symbols as commons_symbols GET_SYMBOL_SEPARATOR = "|" DISPLAY_CANCELLED_TRADES = False def parse_get_symbol(get_symbol): return get_symbol.replace(GET_SYMBOL_SEPARATOR, "/") def get_value_from_dict_or_string(data): if isinstance(data, dict): return data["value"] else: return data def format_trades(dict_trade_history): trade_time_key = "time" trade_price_key = "price" trade_description_key = "trade_description" trade_order_side_key = "order_side" trades = { trade_time_key: [], trade_price_key: [], trade_description_key: [], trade_order_side_key: [] } if not dict_trade_history: return trades for dict_trade in dict_trade_history: status = dict_trade.get(trading_enums.ExchangeConstantsOrderColumns.STATUS.value, trading_enums.OrderStatus.UNKNOWN.value) trade_side = trading_enums.TradeOrderSide(dict_trade[trading_enums.ExchangeConstantsOrderColumns.SIDE.value]) trade_type = trading_api.parse_trade_type(dict_trade) if trade_type in (trading_enums.TraderOrderType.UNSUPPORTED, trading_enums.TraderOrderType.UNKNOWN): trade_type = trade_side if status is not trading_enums.OrderStatus.CANCELED.value or DISPLAY_CANCELLED_TRADES: trade_time = dict_trade[trading_enums.ExchangeConstantsOrderColumns.TIMESTAMP.value] if trade_time > trading_constants.MINIMUM_VAL_TRADE_TIME: trades[trade_time_key].append( timestamp_util.convert_timestamp_to_datetime( trade_time, time_format="%y-%m-%d %H:%M:%S", local_timezone=True ) ) trades[trade_price_key].append( float(dict_trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value])) trades[trade_description_key].append( f"{trade_type.name.replace('_', ' ')}: " f"{dict_trade[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value]} " f"{dict_trade[trading_enums.ExchangeConstantsOrderColumns.QUANTITY_CURRENCY.value]} " f"at {dict_trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]} " f"{dict_trade[trading_enums.ExchangeConstantsOrderColumns.MARKET.value]}") trades[trade_order_side_key].append(trade_side.value) return trades def format_orders(order, min_order_time): time_key = "time" price_key = "price" description_key = "description" order_side_key = "order_side" formatted_orders = { time_key: [], price_key: [], description_key: [], order_side_key: [] } for order in order: if order.creation_time > trading_constants.MINIMUM_VAL_TRADE_TIME: formatted_orders[time_key].append( timestamp_util.convert_timestamp_to_datetime( max(min_order_time, order.creation_time), time_format="%y-%m-%d %H:%M:%S", local_timezone=True ) ) formatted_orders[price_key].append(float(order.origin_price)) formatted_orders[description_key].append( f"{order.order_type.name.replace('_', ' ')}: {order.origin_quantity} {order.quantity_currency} " f"at {order.origin_price}" ) formatted_orders[order_side_key].append(order.side.value) return formatted_orders def _remove_invalid_chars(string): return string.split("[")[0] def _get_candles_reply(exchange, exchange_id, symbol, time_frame): return { "exchange_name": _remove_invalid_chars(exchange), "exchange_id": exchange_id, "symbol": symbol, "time_frame": time_frame.value } def _get_first_exchange_identifiers(exchange_name=None, trading_exchange_only=False): for exchange_manager in interfaces_util.get_exchange_managers(): if trading_exchange_only and not trading_api.is_trader_existing_and_enabled(exchange_manager): continue name = trading_api.get_exchange_name(exchange_manager) if exchange_name is None or name == exchange_name: return exchange_manager, name, trading_api.get_exchange_manager_id(exchange_manager) raise KeyError("No exchange to be found") def get_first_exchange_data(exchange_name=None, trading_exchange_only=False): return _get_first_exchange_identifiers(exchange_name, trading_exchange_only=trading_exchange_only) def get_watched_symbol_data(symbol): symbol_object = commons_symbols.parse_symbol(parse_get_symbol(symbol)) try: last_possibility = {} for exchange_manager in interfaces_util.get_exchange_managers(): exchange_id = trading_api.get_exchange_manager_id(exchange_manager) exchange_name = trading_api.get_exchange_name(exchange_manager) last_possibility = _get_candles_reply( exchange_name, exchange_id, symbol, _get_default_time_frame(exchange_name, exchange_id) ) if symbol_object in trading_api.get_trading_symbols(exchange_manager): return last_possibility # symbol has not been found in exchange, still return the last exchange # in case it becomes available return last_possibility except KeyError: return {} def _get_default_time_frame(exchange_name, exchange_id): available_time_frames = trading_api.get_watched_timeframes( trading_api.get_exchange_manager_from_exchange_name_and_id(exchange_name, exchange_id) ) display_time_frame = commons_enums.TimeFrames(interface_settings.get_display_timeframe()) if display_time_frame in available_time_frames: return display_time_frame return available_time_frames[0] def _is_symbol_data_available(exchange_manager, symbol): return symbol in trading_api.get_trading_pairs(exchange_manager) def get_startup_messages(): return interfaces_util.get_bot_api().get_startup_messages() def get_first_symbol_data(): try: exchange, exchange_name, exchange_id = _get_first_exchange_identifiers() symbol = trading_api.get_trading_pairs(exchange)[0] time_frame = _get_default_time_frame(exchange_name, exchange_id) return _get_candles_reply(exchange_name, exchange_id, symbol, time_frame) except (KeyError, IndexError): return {} def _create_candles_data(exchange_manager, symbol, time_frame, historical_candles, kline, bot_api, list_arrays, in_backtesting, ignore_trades, ignore_orders): candles_key = "candles" trades_key = "trades" orders_key = "orders" symbol_key = "symbol" simulated_key = "simulated" exchange_id_key = "exchange_id" result_dict = { candles_key: {}, trades_key: {}, orders_key: {}, simulated_key: trading_api.is_trader_simulated(exchange_manager), symbol_key: symbol, exchange_id_key: trading_api.get_exchange_manager_id(exchange_manager), } try: data = historical_candles # add kline as the last (current) candle that is not yet in history if math.nan not in kline and data[commons_enums.PriceIndexes.IND_PRICE_TIME.value][-1] != kline[ commons_enums.PriceIndexes.IND_PRICE_TIME.value]: data[commons_enums.PriceIndexes.IND_PRICE_TIME.value] = np.append( data[commons_enums.PriceIndexes.IND_PRICE_TIME.value], kline[commons_enums.PriceIndexes.IND_PRICE_TIME.value]) data[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value] = np.append( data[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value], kline[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value]) data[commons_enums.PriceIndexes.IND_PRICE_LOW.value] = np.append( data[commons_enums.PriceIndexes.IND_PRICE_LOW.value], kline[commons_enums.PriceIndexes.IND_PRICE_LOW.value]) data[commons_enums.PriceIndexes.IND_PRICE_OPEN.value] = np.append( data[commons_enums.PriceIndexes.IND_PRICE_OPEN.value], kline[commons_enums.PriceIndexes.IND_PRICE_OPEN.value]) data[commons_enums.PriceIndexes.IND_PRICE_HIGH.value] = np.append( data[commons_enums.PriceIndexes.IND_PRICE_HIGH.value], kline[commons_enums.PriceIndexes.IND_PRICE_HIGH.value]) data[commons_enums.PriceIndexes.IND_PRICE_VOL.value] = np.append( data[commons_enums.PriceIndexes.IND_PRICE_VOL.value], kline[commons_enums.PriceIndexes.IND_PRICE_VOL.value]) data_x = timestamp_util.convert_timestamps_to_datetime(data[commons_enums.PriceIndexes.IND_PRICE_TIME.value], time_format="%y-%m-%d %H:%M:%S", local_timezone=True) if not ignore_trades: # handle trades after the 1st displayed candle start time for dashboard first_time_to_handle_in_board = data[commons_enums.PriceIndexes.IND_PRICE_TIME.value][0] trades_history = [] if trading_api.is_trader_existing_and_enabled(exchange_manager): trades_history += trading_api.get_trade_history(exchange_manager, None, symbol, first_time_to_handle_in_board, True) result_dict[trades_key] = format_trades(trades_history) if not ignore_orders: if trading_api.is_trader_existing_and_enabled(exchange_manager): result_dict[orders_key] = format_orders( trading_api.get_open_orders(exchange_manager, symbol=symbol), # align time for historical candles only data[commons_enums.PriceIndexes.IND_PRICE_TIME.value][0] if len(data[commons_enums.PriceIndexes.IND_PRICE_TIME.value]) > 2 else 0 ) if list_arrays: result_dict[candles_key] = { enums.PriceStrings.STR_PRICE_TIME.value: data_x, enums.PriceStrings.STR_PRICE_CLOSE.value: data[ commons_enums.PriceIndexes.IND_PRICE_CLOSE.value].tolist(), enums.PriceStrings.STR_PRICE_LOW.value: data[commons_enums.PriceIndexes.IND_PRICE_LOW.value].tolist(), enums.PriceStrings.STR_PRICE_OPEN.value: data[commons_enums.PriceIndexes.IND_PRICE_OPEN.value].tolist(), enums.PriceStrings.STR_PRICE_HIGH.value: data[commons_enums.PriceIndexes.IND_PRICE_HIGH.value].tolist(), enums.PriceStrings.STR_PRICE_VOL.value: data[commons_enums.PriceIndexes.IND_PRICE_VOL.value].tolist() } else: result_dict[candles_key] = { enums.PriceStrings.STR_PRICE_TIME.value: data_x, enums.PriceStrings.STR_PRICE_CLOSE.value: data[commons_enums.PriceIndexes.IND_PRICE_CLOSE.value], enums.PriceStrings.STR_PRICE_LOW.value: data[commons_enums.PriceIndexes.IND_PRICE_LOW.value], enums.PriceStrings.STR_PRICE_OPEN.value: data[commons_enums.PriceIndexes.IND_PRICE_OPEN.value], enums.PriceStrings.STR_PRICE_HIGH.value: data[commons_enums.PriceIndexes.IND_PRICE_HIGH.value] } except IndexError: pass return result_dict def _ensure_time_frame(time_frame: str): try: commons_enums.TimeFrames(time_frame) return time_frame except ValueError: # if timeframe is invalid, use display timefrmae return interface_settings.get_display_timeframe() def get_currency_price_graph_update(exchange_id, symbol, time_frame, list_arrays=True, backtesting=False, minimal_candles=False, ignore_trades=False, ignore_orders=False): bot_api = interfaces_util.get_bot_api() parsed_symbol = commons_symbols.parse_symbol(parse_get_symbol(symbol)) in_backtesting = backtesting_api.is_backtesting_enabled(interfaces_util.get_global_config()) or backtesting exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) symbol_id = str(parsed_symbol) if time_frame is not None: try: time_frame = _ensure_time_frame(time_frame) symbol_data = trading_api.get_symbol_data(exchange_manager, symbol_id, allow_creation=False) limit = 1 if minimal_candles else -1 historical_candles = trading_api.get_symbol_historical_candles(symbol_data, time_frame, limit=limit) kline = [math.nan] if trading_api.has_symbol_klines(symbol_data, time_frame): kline = trading_api.get_symbol_klines(symbol_data, time_frame) if historical_candles is not None: return _create_candles_data(exchange_manager, symbol_id, time_frame, historical_candles, kline, bot_api, list_arrays, in_backtesting, ignore_trades, ignore_orders) except KeyError: traded_pairs = trading_api.get_trading_pairs(exchange_manager) if not traded_pairs or symbol_id in traded_pairs: # not started yet return None else: return {"error": f"no data for {parsed_symbol}"} return None ================================================ FILE: Services/Interfaces/web_interface/models/distributions/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Services.Interfaces.web_interface.models.distributions import market_making from tentacles.Services.Interfaces.web_interface.models.distributions.market_making import ( save_market_making_configuration, get_market_making_services, ) __all__ = [ "save_market_making_configuration", "get_market_making_services", ] ================================================ FILE: Services/Interfaces/web_interface/models/distributions/market_making/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Services.Interfaces.web_interface.models.distributions.market_making import configuration from tentacles.Services.Interfaces.web_interface.models.distributions.market_making.configuration import ( save_market_making_configuration, get_market_making_services, ) __all__ = [ "save_market_making_configuration", "get_market_making_services", ] ================================================ FILE: Services/Interfaces/web_interface/models/distributions/market_making/configuration.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import copy import typing import octobot_commons.constants as commons_constants import octobot_commons.symbols as symbol_utils import octobot_commons.dict_util as dict_util import octobot_commons.logging as commons_logging import octobot_services.interfaces.util as interfaces_util import octobot_tentacles_manager.api import tentacles.Services.Interfaces.web_interface.models.json_schemas as json_schemas import tentacles.Services.Interfaces.web_interface.models.configuration as models_configuration import tentacles.Services.Interfaces.web_interface.models.trading as models_trading import tentacles.Trading.Mode.market_making_trading_mode.market_making_trading as market_making_trading _LOGGER_NAME = "MMConfigurationModel" _MM_SERVICES = [ "telegram", "web" ] def save_market_making_configuration( enabled_exchange: str, trading_pair: typing.Optional[str], exchange_configurations: list[dict], trading_simulator_configuration: dict, simulated_portfolio_configuration: list[dict], trading_mode_name: str, trading_mode_configuration: dict, ) -> None: _save_tentacle_config(trading_mode_name, trading_mode_configuration) reference_exchange = trading_mode_configuration.get( market_making_trading.MarketMakingTradingMode.REFERENCE_EXCHANGE ) _save_user_config( enabled_exchange, reference_exchange, trading_pair, exchange_configurations, trading_simulator_configuration, simulated_portfolio_configuration, ) def get_market_making_services() -> dict: return { name: service for name, service in models_configuration.get_services_list().items() if name in _MM_SERVICES } def _save_user_config( enabled_exchange: typing.Optional[str], reference_exchange: typing.Optional[str], trading_pair: typing.Optional[str], exchange_configurations: list[dict], trading_simulator_configuration: dict, simulated_portfolio_configuration: list[dict], ) -> None: current_edited_config = interfaces_util.get_edited_config(dict_only=False) # exchanges: regenerate the whole configuration exchange_config_update = json_schemas.json_exchange_config_to_config( exchange_configurations, False ) if exchange_config_update and enabled_exchange not in exchange_config_update: # removed enabled exchange from exchange configs: use 1st exchange instead enabled_exchange = next(iter(exchange_config_update)) for exchange in (enabled_exchange, reference_exchange): if ( exchange and exchange != market_making_trading.MarketMakingTradingMode.LOCAL_EXCHANGE_PRICE and exchange in exchange_config_update ): # only enable selected exchange and reference exchanges, force spot trading exchange_config_update[exchange].update({ commons_constants.CONFIG_ENABLED_OPTION: True, commons_constants.CONFIG_EXCHANGE_TYPE: commons_constants.CONFIG_EXCHANGE_SPOT, }) current_exchanges_config = copy.deepcopy(current_edited_config.config[commons_constants.CONFIG_EXCHANGES]) # nested_update_dict to keep nested key/val that might have been in previous config but are not in update # don't pass current_exchanges_config directly to really delete exchanges updated_exchange_config = { exchange: exchange_config for exchange, exchange_config in current_exchanges_config.items() if exchange in exchange_config_update } dict_util.nested_update_dict(updated_exchange_config, exchange_config_update) # currencies: regenerate the whole configuration updated_currencies_config = { trading_pair: { commons_constants.CONFIG_ENABLED_OPTION: True, commons_constants.CONFIG_CRYPTO_PAIRS: [trading_pair] } } if trading_pair else {} # trader simulator simulated_enabled = trading_simulator_configuration[commons_constants.CONFIG_ENABLED_OPTION] updated_simulator_config = copy.deepcopy(current_edited_config.config[commons_constants.CONFIG_SIMULATOR]) previous_simulated_portfolio = copy.deepcopy( updated_simulator_config.get(commons_constants.CONFIG_STARTING_PORTFOLIO) ) simulator_config_update = { **trading_simulator_configuration, **{ commons_constants.CONFIG_STARTING_PORTFOLIO: json_schemas.json_simulated_portfolio_to_config( simulated_portfolio_configuration ) } } updated_portfolio = simulator_config_update[commons_constants.CONFIG_STARTING_PORTFOLIO] changed_portfolio = _filter_0_values(previous_simulated_portfolio) != _filter_0_values(updated_portfolio) # replace portfolio to allow asset removal (otherwise nested_update_dict will never remove assets) updated_simulator_config[commons_constants.CONFIG_STARTING_PORTFOLIO] = updated_portfolio dict_util.nested_update_dict(updated_simulator_config, simulator_config_update) # real trader updated_trader_config = copy.deepcopy(current_edited_config.config[commons_constants.CONFIG_TRADER]) # only update the "enabled" state updated_trader_config[commons_constants.CONFIG_ENABLED_OPTION] = not simulated_enabled # trading updated_trading_config = copy.deepcopy(current_edited_config.config[commons_constants.CONFIG_TRADING]) if trading_pair: # only update the reference market updated_trading_config[commons_constants.CONFIG_TRADER_REFERENCE_MARKET] = ( symbol_utils.parse_symbol(trading_pair).quote ) update = { commons_constants.CONFIG_CRYPTO_CURRENCIES: updated_currencies_config, commons_constants.CONFIG_EXCHANGES: updated_exchange_config, commons_constants.CONFIG_TRADING: updated_trading_config, commons_constants.CONFIG_TRADER: updated_trader_config, commons_constants.CONFIG_SIMULATOR: updated_simulator_config, } # apply & save changes current_edited_config.config.update(update) current_edited_config.save() _get_logger().info( f"Configuration updated. Current profile: {current_edited_config.profile.name}" ) if changed_portfolio: _get_logger().info("Simulated portfolio changed: resetting simulated portfolio content.") models_trading.clear_exchanges_portfolio_history(simulated_only=True) def _filter_0_values(elements: dict) -> dict: return { key: val for key, val in elements.items() if val } def _save_tentacle_config( trading_mode_name: str, trading_mode_configuration: dict, ) -> None: tentacle_class = octobot_tentacles_manager.api.get_tentacle_class_from_string(trading_mode_name) octobot_tentacles_manager.api.update_tentacle_config( interfaces_util.get_edited_tentacles_config(), tentacle_class, trading_mode_configuration, keep_existing=False, ) _get_logger().info( f"{trading_mode_name} configuration updated." ) def _get_logger(): return commons_logging.get_logger(_LOGGER_NAME) ================================================ FILE: Services/Interfaces/web_interface/models/dsl.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.dsl_interpreter as dsl_interpreter import octobot_services.interfaces.util as interfaces_util import tentacles.Meta.DSL_operators.exchange_operators as dsl_operators def get_dsl_keywords_docs() -> list[dsl_interpreter.OperatorDocs]: exchange_managers = interfaces_util.get_exchange_managers() all_operators = list(dsl_interpreter.get_all_operators()) # copy list to avoid modifying the original (cached) list if exchange_managers: # include exchange related operators all_operators += dsl_operators.create_ohlcv_operators( exchange_managers[0], None, None ) all_operators += dsl_operators.create_portfolio_operators( exchange_managers[0] ) return [ operator.get_docs() for operator in all_operators ] ================================================ FILE: Services/Interfaces/web_interface/models/interface_settings.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.constants as trading_constants import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.enums as web_enums import tentacles.Services.Interfaces.web_interface.models.configuration as configuration_model def get_watched_symbols(): config = get_web_interface_config() try: return config[web_interface.WebInterface.WATCHED_SYMBOLS] except KeyError: config[web_interface.WebInterface.WATCHED_SYMBOLS] = [] return config[web_interface.WebInterface.WATCHED_SYMBOLS] def add_watched_symbol(symbol): watched_symbols = get_watched_symbols() if symbol not in watched_symbols: watched_symbols.append(symbol) return _save_edition()[0] return True def remove_watched_symbol(symbol): watched_symbols = get_watched_symbols() try: watched_symbols.remove(symbol) return _save_edition()[0] except ValueError: return True def set_color_mode(color_mode: str): try: get_web_interface_config()[ web_interface.WebInterface.COLOR_MODE ] = web_enums.ColorModes(color_mode).value except ValueError: return False, f"invalid color mode: {color_mode}" return _save_edition() def set_display_announcement(key: str, display: bool): try: get_web_interface_config()[ web_interface.WebInterface.ANNOUNCEMENTS ][key] = display except KeyError: get_web_interface_config()[ web_interface.WebInterface.ANNOUNCEMENTS ] = {key: display} return _save_edition() def get_display_announcement(key: str) -> bool: try: return get_web_interface_config()[ web_interface.WebInterface.ANNOUNCEMENTS ][key] except KeyError: return True def get_color_mode() -> web_enums.ColorModes: return web_enums.ColorModes(get_web_interface_config().get( web_interface.WebInterface.COLOR_MODE, web_enums.ColorModes.DEFAULT.value )) def get_display_timeframe(): return get_web_interface_config().get( web_interface.WebInterface.DISPLAY_TIME_FRAME, trading_constants.DISPLAY_TIME_FRAME.value ) def get_display_orders(): return get_web_interface_config().get(web_interface.WebInterface.DISPLAY_ORDERS, True) def set_display_timeframe(time_frame): get_web_interface_config()[ web_interface.WebInterface.DISPLAY_TIME_FRAME ] = time_frame return _save_edition() def set_display_orders(display_orders): get_web_interface_config()[ web_interface.WebInterface.DISPLAY_ORDERS ] = display_orders return _save_edition() def get_web_interface_config(): try: return get_web_interface().local_config except AttributeError: return {} def _save_edition(): success, message = configuration_model.update_tentacle_config( web_interface.WebInterface.get_name(), get_web_interface().local_config, tentacle_class=web_interface.WebInterface ) reload_config() return success, message def reload_config(): get_web_interface().reload_config() def get_web_interface(): return interfaces_util.get_bot_api().get_interface(web_interface.WebInterface) ================================================ FILE: Services/Interfaces/web_interface/models/json_schemas.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.constants as commons_constants import octobot_commons.configuration as configuration ASSET = "asset" VALUE = "value" HIDDEN_VALUE = "******" NAME = "name" API_KEY = "api-key" API_SECRET = "api-secret" API_PASSWORD = "api-password" JSON_PORTFOLIO_SCHEMA = { "type": "array", "uniqueItems": True, "title": "Simulated portfolio", "format": "table", "items": { "type": "object", "title": "Asset", "properties": { ASSET: { "title": "Asset", "type": "string", "enum": [], }, VALUE: { "title": "Holding", "type": "number", "minimum": 0, }, } } } def get_json_simulated_portfolio(user_config): config_portfolio = user_config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO] return [ { ASSET: asset, VALUE: value, } for asset, value in config_portfolio.items() ] def json_simulated_portfolio_to_config(json_portfolio_config: list[dict]) -> dict: return { entry[ASSET]: entry[VALUE] for entry in json_portfolio_config } JSON_TRADING_SIMULATOR_SCHEMA = { "type": "object", "title": "Simulated trading configuration", "additionalProperties": False, "properties": { "enabled": { "title": "Enable trading simulator When checked, OctoBot will trade with the simulated portfolio", "type": "boolean", "format": "checkbox", "options": { "containerAttributes": { "class": "mb-3" } } }, "fees": { "type": "object", "additionalProperties": False, "title": "Trading simulator fees", "properties": { "maker": { "title": "Taker fees: maker trading fee as a % of the trade total cost.", "type": "number", "minimum": -100, "maximum": 100 }, "taker": { "title": "Taker fees: taker trading fee as a % of the trade total cost.", "type": "number", "minimum": -100, "maximum": 100, "step": 0.01 } } } } } def get_json_trading_simulator_config(user_config: dict) -> dict: return { key: val for key, val in user_config[commons_constants.CONFIG_SIMULATOR].items() if key in (commons_constants.CONFIG_ENABLED_OPTION, commons_constants.CONFIG_SIMULATOR_FEES) } def get_json_exchanges_schema(exchanges: list[str]) -> dict: return { "type": "array", "uniqueItems": True, "title": "Exchanges", "format": "table", "additionalProperties": False, "items": { "type": "object", "id": "exchanges", "title": "Exchange", "additionalProperties": False, "properties": { NAME: { "title": "Name", "type": "string", "enum": exchanges, "propertyOrder": 1, }, API_KEY: { "title": "API key: your API key for this exchange", "type": "string", "minLength": 0, "propertyOrder": 2, }, API_SECRET: { "title": "API secret: your API secret for this exchange", "type": "string", "minLength": 0, "propertyOrder": 3, }, API_PASSWORD: { "title": "API password: leave empty if not required by exchange", "type": "string", "minLength": 0, "propertyOrder": 4, }, } } } def get_json_exchange_config(user_config: dict): return [ { NAME: name, API_KEY: "" if configuration.has_invalid_default_config_value(values.get(commons_constants.CONFIG_EXCHANGE_KEY)) else HIDDEN_VALUE, API_SECRET: "" if configuration.has_invalid_default_config_value(values.get(commons_constants.CONFIG_EXCHANGE_SECRET)) else HIDDEN_VALUE, API_PASSWORD: "" if configuration.has_invalid_default_config_value(values.get(commons_constants.CONFIG_EXCHANGE_PASSWORD)) else HIDDEN_VALUE, } for name, values in user_config[commons_constants.CONFIG_EXCHANGES].items() ] def json_exchange_config_to_config(json_exchanges_config: list[dict], enabled: bool): return { config[NAME]: _get_exchange_config_from_json(config, enabled) for config in json_exchanges_config } def _get_exchange_config_from_json(json_exchange_config: dict, enabled: bool) -> dict: config = { commons_constants.CONFIG_ENABLED_OPTION: enabled, } for json_key, config_key in ( (API_KEY, commons_constants.CONFIG_EXCHANGE_KEY), (API_SECRET, commons_constants.CONFIG_EXCHANGE_SECRET), (API_PASSWORD, commons_constants.CONFIG_EXCHANGE_PASSWORD), (API_KEY, commons_constants.CONFIG_EXCHANGE_KEY), ): json_value = json_exchange_config[json_key] if json_value != HIDDEN_VALUE: # only add keys if their value is not HIDDEN_VALUE, use commons_constants.EMPTY_VALUE instead of "" config[config_key] = json_value or commons_constants.NO_KEY_VALUE return config ================================================ FILE: Services/Interfaces/web_interface/models/logs.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import shutil import octobot.constants as constants LOG_EXPORT_FORMAT = "zip" def export_logs(export_path): shutil.make_archive(export_path, "zip", constants.LOGS_FOLDER) return f"{export_path}.{LOG_EXPORT_FORMAT}" ================================================ FILE: Services/Interfaces/web_interface/models/medias.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_tentacles_manager.constants as tentacles_manager_constants import octobot_commons.constants as commons_constants ALLOWED_IMAGE_FORMATS = ["png", "jpg", "jpeg", "gif", "svg"] ALLOWED_SOUNDS_FORMATS = ["mp3"] def _is_valid_path(path, header): return path.startswith(header) and ".." not in path def is_valid_tentacle_image_path(path): path_ending = path.split(".")[-1].lower() return path_ending in ALLOWED_IMAGE_FORMATS and _is_valid_path(path, tentacles_manager_constants.TENTACLES_PATH) def is_valid_profile_image_path(path): path_ending = path.split(".")[-1].lower() return path_ending in ALLOWED_IMAGE_FORMATS and _is_valid_path(path, commons_constants.USER_PROFILES_FOLDER) def is_valid_audio_path(path): path_ending = path.split(".")[-1].lower() return path_ending in ALLOWED_SOUNDS_FORMATS and _is_valid_path(path, "") ================================================ FILE: Services/Interfaces/web_interface/models/profiles.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import octobot_services.interfaces.util as interfaces_util import octobot_commons.profiles as profiles import octobot_commons.errors as errors import octobot_commons.enums as commons_enums import octobot_commons.authentication as authentication import octobot_trading.util as trading_util import octobot_tentacles_manager.api as tentacles_manager_api import octobot.constants as constants import octobot.community as community import octobot.community.errors as community_errors ACTIVATION = "activation" VERSION = "version" IMPORTED = "imported" REQUIRE_EXACT_VERSION = "require_exact_version" READ_ERROR = "read_error" _PROFILE_TENTACLES_CONFIG_CACHE = {} def get_current_profile(): return interfaces_util.get_edited_config(dict_only=False).profile def duplicate_profile(profile_id): to_duplicate = get_profile(profile_id) new_profile = to_duplicate.duplicate(name=f"{to_duplicate.name}_(copy)", description=to_duplicate.description) tentacles_manager_api.refresh_profile_tentacles_setup_config(new_profile.path) interfaces_util.get_edited_config(dict_only=False).load_profiles() return get_profile(new_profile.profile_id) def convert_to_live_profile(profile_id): profile = get_profile(profile_id) profile.profile_type = commons_enums.ProfileType.LIVE profile.validate_and_save_config() def select_profile(profile_id): _select_and_save(interfaces_util.get_edited_config(dict_only=False), profile_id) def _select_and_save(config, profile_id): config.select_profile(profile_id) _update_edited_tentacles_config(config) config.save() def _update_edited_tentacles_config(config): updated_tentacles_config = tentacles_manager_api.get_tentacles_setup_config(config.get_tentacles_config_path()) interfaces_util.set_edited_tentacles_config(updated_tentacles_config) def get_profile(profile_id): return interfaces_util.get_edited_config(dict_only=False).profile_by_id[profile_id] def get_tentacles_setup_config_from_profile_id(profile_id): return get_tentacles_setup_config_from_profile(get_profile(profile_id)) def get_tentacles_setup_config_from_profile(profile): return tentacles_manager_api.get_tentacles_setup_config( profile.get_tentacles_config_path() ) def get_profiles(profile_type: commons_enums.ProfileType = None): return { identifier: profile for identifier, profile in interfaces_util.get_edited_config(dict_only=False).profile_by_id.items() if profile_type is None or profile.profile_type is profile_type } def _get_profile_setup_config(profile, reloading_profile): if profile.profile_id == reloading_profile: _PROFILE_TENTACLES_CONFIG_CACHE.pop(reloading_profile, None) return tentacles_manager_api.get_tentacles_setup_config( profile.get_tentacles_config_path() ) try: _PROFILE_TENTACLES_CONFIG_CACHE[profile.profile_id] except KeyError: _PROFILE_TENTACLES_CONFIG_CACHE[profile.profile_id] = \ tentacles_manager_api.get_tentacles_setup_config( profile.get_tentacles_config_path() ) return _PROFILE_TENTACLES_CONFIG_CACHE[profile.profile_id] def get_profiles_tentacles_details(profiles_list): tentacles_by_profile_id = {} current_profile_id = get_current_profile().profile_id for profile in profiles_list.values(): try: # force reload for current profile as tentacles setup config can change tentacles_setup_config = _get_profile_setup_config(profile, current_profile_id) tentacles_by_profile_id[profile.profile_id] = { ACTIVATION: tentacles_manager_api.get_activated_tentacles(tentacles_setup_config), VERSION: tentacles_manager_api.get_tentacles_installation_version(tentacles_setup_config), IMPORTED: profile.imported, REQUIRE_EXACT_VERSION: False, # implement if exact version is required in profiles READ_ERROR: not tentacles_manager_api.is_tentacles_setup_config_successfully_loaded(tentacles_setup_config), } except Exception: # do not raise here to prevent avoid config display pass return tentacles_by_profile_id def update_profile(profile_id, json_profile_desc, json_profile_content=None): profile = get_profile(profile_id) new_name = json_profile_desc.get("name", profile.name) renamed = profile.name != new_name if renamed and get_current_profile().profile_id == profile_id: return False, "Can't rename the active profile" profile.name = new_name profile.description = json_profile_desc.get("description", profile.description) profile.avatar = json_profile_desc.get("avatar", profile.avatar) profile.complexity = commons_enums.ProfileComplexity(int(json_profile_desc.get("complexity", profile.complexity.value))) profile.risk = commons_enums.ProfileRisk(int(json_profile_desc.get("risk", profile.risk.value))) if json_profile_content is not None: profile.config = json_profile_content profile.validate_and_save_config() if renamed: profile.rename_folder(new_name, False) return True, "Profile updated" def remove_profile(profile_id): profile = None if get_current_profile().profile_id == profile_id: return profile, "Can't remove the active profile" try: profile = get_profile(profile_id) interfaces_util.get_edited_config(dict_only=False).remove_profile(profile_id) except errors.ProfileRemovalError as err: return profile, err return profile, None def export_profile(profile_id, export_path) -> str: return profiles.export_profile(get_profile(profile_id), export_path) def import_profile(profile_path, name, profile_url=None): profile = profiles.import_profile(profile_path, constants.PROFILE_FILE_SCHEMA, name=name, origin_url=profile_url) interfaces_util.get_edited_config(dict_only=False).load_profiles() return profile def import_strategy_as_profile(authenticator, strategy: community.StrategyData, name: str, description: str): if strategy.is_extension_only() and not authenticator.has_open_source_package(): raise community_errors.ExtensionRequiredError( f"The {constants.OCTOBOT_EXTENSION_PACKAGE_1_NAME} is required to install this strategy" ) profile_data = interfaces_util.run_in_bot_main_loop(authenticator.get_strategy_profile_data(strategy.id)) profile = interfaces_util.run_in_bot_main_loop( profiles.import_profile_data_as_profile( profile_data, constants.PROFILE_FILE_SCHEMA, interfaces_util.get_bot_api().get_aiohttp_session(), name=name, description=description, risk=strategy.get_risk(), origin_url=strategy.get_product_url(), logo_url=strategy.logo_url, auto_update=strategy.is_auto_updated(), force_simulator=True ) ) interfaces_util.get_edited_config(dict_only=False).load_profiles() return profile def download_and_import_profile(profile_url): name = profile_url.split('/')[-1] if "?" in name: # remove parameter name = name.split("?")[0] file_path = profiles.download_profile(profile_url, name) profile = import_profile(file_path, name, profile_url=profile_url) if os.path.isfile(file_path): os.remove(file_path) return profile def get_profile_name(profile_id) -> str: return get_profile(profile_id).name def get_forced_profile() -> profiles.Profile: if constants.FORCED_PROFILE: # env variables are priority 1 return get_current_profile() try: startup_info = interfaces_util.run_in_bot_main_loop( authentication.Authenticator.instance().get_startup_info(), log_exceptions=False ) if startup_info.forced_profile_url: return get_current_profile() except community.BotError: pass return None def is_real_trading(profile): if trading_util.is_trader_enabled(profile.config): return True return False ================================================ FILE: Services/Interfaces/web_interface/models/strategy_optimizer.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import threading import octobot.api as octobot_api import octobot.constants as octobot_constants import octobot_commons.logging as bot_logging import octobot_commons.tentacles_management as tentacles_management import octobot_commons.time_frame_manager as time_frame_manager import octobot_evaluators.evaluators as evaluators import octobot_evaluators.api as evaluators_api import octobot_services.interfaces.util as interfaces_util import tentacles.Services.Interfaces.web_interface as web_interface_root import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Evaluator.Strategies as TentaclesStrategies LOGGER = bot_logging.get_logger(__name__) def get_strategies_list(trading_mode): try: return trading_mode.get_required_strategies_names_and_count(interfaces_util.get_startup_tentacles_config())[0] except Exception: return [] def get_time_frames_list(strategy_name): if strategy_name: strategy_class = tentacles_management.get_class_from_string(strategy_name, evaluators.StrategyEvaluator, TentaclesStrategies, tentacles_management.evaluator_parent_inspection) return [tf.value for tf in strategy_class.get_required_time_frames( interfaces_util.get_global_config(), interfaces_util.get_bot_api().get_tentacles_setup_config())] else: return [] def get_evaluators_list(strategy_name): if strategy_name: strategy_class = tentacles_management.get_class_from_string(strategy_name, evaluators.StrategyEvaluator, TentaclesStrategies, tentacles_management.evaluator_parent_inspection) found_evaluators = evaluators_api.get_relevant_TAs_for_strategy( strategy_class, interfaces_util.get_bot_api().get_tentacles_setup_config()) return set(evaluator.get_name() for evaluator in found_evaluators) else: return [] def get_risks_list(): return [i / 10 for i in range(10, 0, -1)] def cancel_optimizer(): tools = web_interface_root.WebInterface.tools optimizer = tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] if optimizer is None: return False, "No optimizer is running" octobot_api.cancel_strategy_optimizer(optimizer) return True, "Optimizer is being cancelled" def start_optimizer(strategy, time_frames, evaluators, risks): if not octobot_constants.ENABLE_BACKTESTING: return False, "Backtesting is disabled" try: tools = web_interface_root.WebInterface.tools optimizer = tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] if optimizer is not None and octobot_api.is_optimizer_computing(optimizer): return False, "Optimizer already running" independent_backtesting = tools[constants.BOT_TOOLS_BACKTESTING] if independent_backtesting and octobot_api.is_independent_backtesting_in_progress(independent_backtesting): return False, "A backtesting is already running" formatted_time_frames = time_frame_manager.parse_time_frames(time_frames) float_risks = [float(risk) for risk in risks] temp_independent_backtesting = octobot_api.create_independent_backtesting( interfaces_util.get_global_config(), None, []) optimizer_config = interfaces_util.run_in_bot_async_executor( octobot_api.initialize_independent_backtesting_config(temp_independent_backtesting) ) optimizer = octobot_api.create_strategy_optimizer(optimizer_config, interfaces_util.get_bot_api().get_edited_tentacles_config(), strategy) tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] = optimizer thread = threading.Thread(target=octobot_api.find_optimal_configuration, args=(optimizer, evaluators, formatted_time_frames, float_risks), name=f"{optimizer.get_name()}-WebInterface-runner") thread.start() return True, "Optimizer started" except Exception as e: LOGGER.exception(e, True, f"Error when starting optimizer: {e}") raise e def get_optimizer_results(): optimizer = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] if optimizer: results = octobot_api.get_optimizer_results(optimizer) return [result.get_result_dict(i) for i, result in enumerate(results)] else: return [] def get_optimizer_report(): if get_optimizer_status()[0] == "finished": optimizer = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] return octobot_api.get_optimizer_report(optimizer) else: return [] def get_current_run_params(): params = { "strategy_name": [], "time_frames": [], "evaluators": [], "risks": [], "trading_mode": [] } if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER]: optimizer = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] params = { "strategy_name": [octobot_api.get_optimizer_strategy(optimizer).get_name()], "time_frames": [tf.value for tf in octobot_api.get_optimizer_all_time_frames(optimizer)], "evaluators": octobot_api.get_optimizer_all_TAs(optimizer), "risks": octobot_api.get_optimizer_all_risks(optimizer), "trading_mode": [octobot_api.get_optimizer_trading_mode(optimizer)] } return params def get_optimizer_status(): optimizer = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_STRATEGY_OPTIMIZER] if optimizer: if octobot_api.is_optimizer_computing(optimizer): overall_progress, remaining_time =\ interfaces_util.run_in_bot_async_executor(octobot_api.get_optimizer_overall_progress(optimizer)) return "computing", octobot_api.get_optimizer_current_test_suite_progress(optimizer), \ overall_progress, remaining_time, \ octobot_api.get_optimizer_errors_description(optimizer) else: status = "finished" if octobot_api.is_optimizer_finished(optimizer) else "starting" return status, 100, 100, 0, octobot_api.get_optimizer_errors_description(optimizer) else: return "not started", 0, 0, 0, None ================================================ FILE: Services/Interfaces/web_interface/models/tentacles.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot.constants as octobot_constants import octobot.configuration_manager as configuration_manager import octobot_commons.logging as bot_logging import octobot_services.interfaces.util as interfaces_util import octobot_tentacles_manager.api as tentacles_manager_api import octobot_tentacles_manager.constants as tentacles_manager_constants logger = bot_logging.get_logger("TentaclesModel") def get_tentacles_packages(): return tentacles_manager_api.get_registered_tentacle_packages( interfaces_util.get_bot_api().get_edited_tentacles_config()) def call_tentacle_manager(coro, *args, **kwargs): return interfaces_util.run_in_bot_main_loop(coro(*args, **kwargs)) == 0 def _add_version_to_tentacles_package_path(path_or_url, version): return f"{path_or_url}/{version.replace('.', tentacles_manager_constants.ARTIFACT_VERSION_DOT_REPLACEMENT)}" def get_official_tentacles_url(use_beta_tentacles) -> str: return configuration_manager.get_default_tentacles_url( version=octobot_constants.BETA_TENTACLE_PACKAGE_NAME if use_beta_tentacles else None ) def install_packages(path_or_url=None, version=None, authenticator=None): message = "Tentacles installed. Restart your OctoBot to load the new tentacles." success = True if path_or_url and version: path_or_url = _add_version_to_tentacles_package_path(path_or_url, version) for package_url in [path_or_url] if path_or_url else \ tentacles_manager_api.get_registered_tentacle_packages( interfaces_util.get_bot_api().get_edited_tentacles_config()).values(): if not package_url == tentacles_manager_constants.UNKNOWN_TENTACLES_PACKAGE_LOCATION: if not call_tentacle_manager(tentacles_manager_api.install_all_tentacles, package_url, setup_config=interfaces_util.get_bot_api().get_edited_tentacles_config(), aiohttp_session=interfaces_util.get_bot_api().get_aiohttp_session(), bot_install_dir=octobot_constants.OCTOBOT_FOLDER, authenticator=authenticator ): success = False else: message = "Tentacles installed however it is impossible to re-install tentacles with unknown package origin" # reload profiles to display newly installed ones if any interfaces_util.get_edited_config(dict_only=False).load_profiles() if success: return message return False def update_packages(authenticator=None): message = "Tentacles updated" success = True for package_url in tentacles_manager_api.get_registered_tentacle_packages( interfaces_util.get_bot_api().get_edited_tentacles_config()).values(): if package_url != tentacles_manager_constants.UNKNOWN_TENTACLES_PACKAGE_LOCATION: if not call_tentacle_manager(tentacles_manager_api.update_all_tentacles, package_url, aiohttp_session=interfaces_util.get_bot_api().get_aiohttp_session(), authenticator=authenticator): success = False else: message = "Tentacles updated however it is impossible to update tentacles with unknown package origin" if success: return message return False def reset_packages(): if call_tentacle_manager(tentacles_manager_api.uninstall_all_tentacles, setup_config=interfaces_util.get_bot_api().get_edited_tentacles_config(), use_confirm_prompt=False): return "Reset successful" else: return None def update_modules(modules): success = True for url in [ get_official_tentacles_url(False), # tentacles_manager_api.get_compiled_tentacles_url( # octobot_constants.DEFAULT_COMPILED_TENTACLES_URL, # octobot_constants.TENTACLES_REQUIRED_VERSION # ) ]: try: call_tentacle_manager(tentacles_manager_api.update_tentacles, modules, url, aiohttp_session=interfaces_util.get_bot_api().get_aiohttp_session(), quite_mode=True) except Exception: success = False if success: return f"{len(modules)} Tentacles updated" return None def uninstall_modules(modules): if call_tentacle_manager(tentacles_manager_api.uninstall_tentacles, modules): return f"{len(modules)} Tentacles uninstalled" else: return None def get_tentacles(): return tentacles_manager_api.get_installed_tentacles_modules() ================================================ FILE: Services/Interfaces/web_interface/models/trading.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import time import sortedcontainers import octobot_services.interfaces.util as interfaces_util import octobot_trading.api as trading_api import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.logging as logging import octobot_commons.timestamp_util as timestamp_util import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.pretty_printer as pretty_printer import octobot_commons.symbols as commons_symbols import tentacles.Services.Interfaces.web_interface.errors as errors import tentacles.Services.Interfaces.web_interface.models.dashboard as dashboard import tentacles.Services.Interfaces.web_interface.models.configuration as configuration def ensure_valid_exchange_id(exchange_id) -> str: try: trading_api.get_exchange_manager_from_exchange_id(exchange_id) except KeyError as e: raise errors.MissingExchangeId() from e def get_exchange_watched_time_frames(exchange_id): try: exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) return trading_api.get_watched_timeframes(exchange_manager), trading_api.get_exchange_name(exchange_manager) except KeyError: return [], "" def get_all_watched_time_frames(): time_frames = [] for exchange_manager in trading_api.get_exchange_managers_from_exchange_ids(trading_api.get_exchange_ids()): if not trading_api.get_is_backtesting(exchange_manager): time_frames += trading_api.get_watched_timeframes(exchange_manager) return time_frame_manager.sort_time_frames(list(set(time_frames))) def get_initializing_currencies_prices_set(fetch_timeout): initializing_currencies = set() # if fetch_timeout is > 0 and prices are still missing after fetch_timeout, ignore them if fetch_timeout and time.time() > interfaces_util.get_bot_api().get_start_time() + fetch_timeout: return initializing_currencies for exchange_manager in interfaces_util.get_exchange_managers(): initializing_currencies = initializing_currencies.union( trading_api.get_initializing_currencies_prices(exchange_manager)) return initializing_currencies def get_evaluation(symbol, exchange_name, exchange_id): try: if exchange_name: exchange_manager = trading_api.get_exchange_manager_from_exchange_name_and_id(exchange_name, exchange_id) for trading_mode in trading_api.get_trading_modes(exchange_manager): if trading_api.get_trading_mode_symbol(trading_mode) == symbol: state_desc, val_state = trading_api.get_trading_mode_current_state(trading_mode) try: val_state = round(val_state) except TypeError: pass return f"{state_desc.replace('_', ' ')}, {val_state}" except KeyError: pass return "N/A" def get_exchanges_load(): return { trading_api.get_exchange_name(exchange_manager): { "load": trading_api.get_currently_handled_pair_with_time_frame(exchange_manager), "max_load": trading_api.get_max_handled_pair_with_time_frame(exchange_manager), "overloaded": trading_api.is_overloaded(exchange_manager), "has_websocket": trading_api.get_has_websocket(exchange_manager), "has_reached_websocket_limit": trading_api.get_has_reached_websocket_limit(exchange_manager) } for exchange_manager in interfaces_util.get_exchange_managers() } def _add_exchange_portfolio(portfolio, exchange, holdings_per_symbol): exchanges_key = "exchanges" total_key = "total" free_key = "free" locked_key = "locked" for currency, amounts in portfolio.items(): total_amount = amounts.total free_amount = amounts.available if total_amount > 0: if currency not in holdings_per_symbol: holdings_per_symbol[currency] = { exchanges_key: {} } holdings_per_symbol[currency][exchanges_key][exchange] = { total_key: total_amount, free_key: free_amount, locked_key: total_amount - free_amount, } holdings_per_symbol[currency][total_key] = holdings_per_symbol[currency].get(total_key, 0) + total_amount holdings_per_symbol[currency][free_key] = holdings_per_symbol[currency].get(free_key, 0) + free_amount holdings_per_symbol[currency][locked_key] = holdings_per_symbol[currency][total_key] - \ holdings_per_symbol[currency][free_key] def get_exchange_holdings_per_symbol(): holdings_per_symbol = {} for exchange_manager in configuration.get_live_trading_enabled_exchange_managers(): portfolio = trading_api.get_portfolio(exchange_manager) _add_exchange_portfolio(portfolio, trading_api.get_exchange_name(exchange_manager), holdings_per_symbol) return holdings_per_symbol def get_symbols_values(symbols, has_real_trader, has_simulated_trader): loading = 0 value_per_symbols = {symbol: loading for symbol in symbols} real_portfolio_holdings, simulated_portfolio_holdings = interfaces_util.get_portfolio_holdings() portfolio = real_portfolio_holdings if has_real_trader else simulated_portfolio_holdings value_per_symbols.update(portfolio) return value_per_symbols def _get_exchange_historical_portfolio(exchange_manager, currency, time_frame, from_timestamp, to_timestamp) -> list: return [ { trading_enums.HistoricalPortfolioValue.TIME.value: value[trading_enums.HistoricalPortfolioValue.TIME.value], trading_enums.HistoricalPortfolioValue.VALUE.value: pretty_printer.get_min_string_from_number( value[trading_enums.HistoricalPortfolioValue.VALUE.value] ) } for value in trading_api.get_portfolio_historical_values( exchange_manager, currency, time_frame, from_timestamp=from_timestamp, to_timestamp=to_timestamp ) ] def _merge_all_exchanges_historical_portfolio(currency, time_frame, from_timestamp, to_timestamp): merged_result = sortedcontainers.SortedDict() for exchange_manager in configuration.get_live_trading_enabled_exchange_managers(): for value in _get_exchange_historical_portfolio( exchange_manager, currency, time_frame, from_timestamp, to_timestamp ): if value[trading_enums.HistoricalPortfolioValue.TIME.value] not in merged_result: merged_result[value[trading_enums.HistoricalPortfolioValue.TIME.value]] = \ value[trading_enums.HistoricalPortfolioValue.VALUE.value] else: merged_result[value[trading_enums.HistoricalPortfolioValue.TIME.value]] += str(decimal.Decimal( value[trading_enums.HistoricalPortfolioValue.VALUE.value] )) return [ { trading_enums.HistoricalPortfolioValue.TIME.value: key, trading_enums.HistoricalPortfolioValue.VALUE.value: val, } for key, val in merged_result.items() ] def get_portfolio_historical_values(currency, time_frame=None, from_timestamp=None, to_timestamp=None, exchange=None): time_frame = commons_enums.TimeFrames(time_frame) if time_frame else commons_enums.TimeFrames.ONE_DAY if exchange is None: return _merge_all_exchanges_historical_portfolio(currency, time_frame, from_timestamp, to_timestamp) return _get_exchange_historical_portfolio( dashboard.get_first_exchange_data(exchange, trading_exchange_only=True)[0], currency, time_frame, from_timestamp, to_timestamp ) def _get_valid_pnl_history(exchange_manager, quote, symbol, since): return [ pnl for pnl in trading_api.get_completed_pnl_history( exchange_manager, quote=quote, symbol=symbol, since=since ) if _is_valid_pnl(pnl) ] def _is_valid_pnl(pnl): try: return pnl.get_entry_time() and pnl.get_close_time() except trading_errors.IncompletePNLError: return False def _get_pnl_history(exchange, quote, symbol, since): if exchange: return { exchange: _get_valid_pnl_history( dashboard.get_first_exchange_data(exchange, trading_exchange_only=True)[0], quote, symbol, since ) } history = {} for exchange_manager in configuration.get_live_trading_enabled_exchange_managers(): history[trading_api.get_exchange_name(exchange_manager)] = _get_valid_pnl_history( exchange_manager, quote, symbol, since ) return history def get_pnl_history_symbols(exchange=None, quote=None, symbol=None, since=None): return set( historical_pnl.entries[0].symbol for exchange_name, historical_pnl_elements in _get_pnl_history(exchange, quote, symbol, since).items() for historical_pnl in historical_pnl_elements if historical_pnl.entries ) def _convert_timestamp(timestamp): return timestamp_util.convert_timestamp_to_datetime(timestamp, time_format='%Y-%m-%d %H:%M:%S', local_timezone=True) def get_pnl_history(exchange=None, quote=None, symbol=None, since=None, scale=None): ENTRY_PRICE = "en_p" EXIT_PRICE = "ex_p" ENTRY_TIME = "en_t" ENTRY_DATE = "en_d" EXIT_TIME = "ex_t" EXIT_DATE = "ex_d" ENTRY_SIDE = "en_s" EXIT_SIDE = "ex_s" ENTRY_AMOUNT = "en_a" EXIT_AMOUNT = "ex_a" DETAILS = "d" PNL = "pnl" PNL_AMOUNT = "pnl_a" EXCHANGE = "ex" FEES = "f" SPECIAL_FEES = "s_f" BASE = "b" QUOTE = "q" CURRENCY = "c" SYMBOL = "s" TRADES_COUNT = "tc" pnl_history = {} use_detailed_history = not(scale) scale_seconds = commons_enums.TimeFramesMinutes[commons_enums.TimeFrames(scale)] * \ commons_constants.MINUTE_TO_SECONDS if scale else 1 symbol = symbol or None # set quote filter to None when symbol is not provided quote = None if symbol else quote history_by_exchange = _get_pnl_history(exchange, quote, symbol, since) invalid_pnls = 0 for exchange_name, historical_pnl_elements in history_by_exchange.items(): for historical_pnl in historical_pnl_elements: try: close_time = historical_pnl.get_close_time() scaled_time = close_time - (close_time % scale_seconds) pnl, pnl_p = historical_pnl.get_profits() pnl_a = historical_pnl.get_closed_close_value() if scaled_time not in pnl_history: pnl_history[scaled_time] = { PNL: pnl, PNL_AMOUNT: pnl_a, QUOTE: historical_pnl.entries[0].market, TRADES_COUNT: len(historical_pnl.entries) + len(historical_pnl.closes), DETAILS: None } else: pnl_val = pnl_history[scaled_time] pnl_val[PNL] += pnl pnl_val[PNL_AMOUNT] += pnl_a pnl_val[TRADES_COUNT] += len(historical_pnl.entries) + len(historical_pnl.closes) if use_detailed_history: pnl_history[scaled_time][DETAILS] = { ENTRY_TIME: historical_pnl.get_entry_time(), ENTRY_DATE: _convert_timestamp(historical_pnl.get_entry_time()), ENTRY_PRICE: float(historical_pnl.get_entry_price()), EXIT_PRICE: float(historical_pnl.get_close_price()), ENTRY_SIDE: historical_pnl.entries[0].side.value, EXIT_SIDE: historical_pnl.closes[0].side.value, ENTRY_AMOUNT: historical_pnl.get_total_entry_quantity(), EXIT_AMOUNT: historical_pnl.get_total_close_quantity(), SYMBOL: historical_pnl.entries[0].symbol, FEES: float(historical_pnl.get_paid_regular_fees_in_quote()), SPECIAL_FEES: [ { CURRENCY: currency, FEES: float(value), } for currency, value in historical_pnl.get_paid_special_fees_by_currency().items() ], BASE: historical_pnl.entries[0].currency, EXCHANGE: exchange_name, } except trading_errors.IncompletePNLError: invalid_pnls += 1 if invalid_pnls: logging.get_logger("TradingModel").warning(f"{invalid_pnls} invalid TradePNLs in history") return sorted( [ { EXIT_TIME: t, EXIT_DATE: _convert_timestamp(t), PNL: float(pnl[PNL]), PNL_AMOUNT: float(pnl[PNL_AMOUNT]), QUOTE: pnl[QUOTE], TRADES_COUNT: pnl[TRADES_COUNT], DETAILS: pnl[DETAILS], } for t, pnl in pnl_history.items() # skip 0 value pnl in detailed history if not use_detailed_history or (pnl[PNL] or pnl.get(DETAILS, {}).get(SPECIAL_FEES, 0)) ], key=lambda x: x[EXIT_TIME] ) def _get_dumped_data(real, simulated, dump_func): return [ dumped for dumped in tuple( dump_func(order, False) for order in real ) + tuple( dump_func(order, True) for order in simulated ) if dumped is not None ] SYMBOL = "symbol" TYPE = "type" PRICE = "price" AMOUNT = "amount" EXCHANGE = "exchange" TIME = "time" DATE = "date" COST = "cost" MARKET = "market" SIMULATED_OR_REAL = "SoR" ID = "id" FEE_COST = "fee_cost" REF_MARKET_COST = "ref_market_cost" FEE_CURRENCY = "fee_currency" SIDE = "side" CONTRACT = "contract" VALUE = "value" ENTRY_PRICE = "entry_price" LIQUIDATION_PRICE = "liquidation_price" MARGIN = "margin" UNREALIZED_PNL = "unrealized_pnl" def _dump_order(order, is_simulated): try: market = _get_market(order.symbol) return { SYMBOL: order.symbol, TYPE: order.order_type.name.replace("_", " "), PRICE: order.origin_price if not order.origin_stop_price else order.origin_stop_price, AMOUNT: order.origin_quantity, EXCHANGE: order.exchange_manager.exchange.name if order.exchange_manager else '', DATE: _convert_timestamp(order.creation_time), TIME: order.creation_time, COST: _convert_amount(order.exchange_manager, order.total_cost, market) or order.total_cost, MARKET: market, SIMULATED_OR_REAL: ( "Simulated" if is_simulated else "Virtual" if order.is_self_managed() else "Inactive" if not order.is_active else "Real" ), ID: order.order_id, } except Exception as err: logging.get_logger("TradingModel").exception(f"Error when dumping order {err}, order: {order}") return None def get_all_orders_data(): return _get_dumped_data(*interfaces_util.get_all_open_orders(), _dump_order) def _convert_amount(exchange_manager, amount, currency): multiplier = trading_api.get_currency_ref_market_value(exchange_manager, currency) if multiplier is None: return None return float(multiplier * amount) def _dump_trade(trade, is_simulated): try: market = _get_market(trade.symbol) return { SYMBOL: trade.symbol, TYPE: trade.trade_type.name.replace("_", " "), PRICE: trade.executed_price, AMOUNT: trade.executed_quantity, EXCHANGE: trade.exchange_manager.exchange.name if trade.exchange_manager else '', DATE: _convert_timestamp(trade.executed_time), TIME: trade.executed_time, COST: trade.total_cost, REF_MARKET_COST: _convert_amount(trade.exchange_manager, trade.total_cost, market), MARKET: market, FEE_COST: trade.fee.get(trading_enums.FeePropertyColumns.COST.value, 0) if trade.fee else 0, FEE_CURRENCY: trade.fee.get(trading_enums.FeePropertyColumns.CURRENCY.value, '') if trade.fee else '', SIMULATED_OR_REAL: "Simulated" if is_simulated else "Real", ID: trade.trade_id, } except Exception as err: logging.get_logger("TradingModel").exception(f"Error when dumping trade {err}, trade: {trade}") return None def get_all_trades_data(independent_backtesting=None): return _get_dumped_data(*interfaces_util.get_trades_history(independent_backtesting=independent_backtesting), _dump_trade) def _get_market(symbol_str): symbol = commons_symbols.parse_symbol(symbol_str) return symbol.settlement_asset or symbol.quote def _dump_position(position, is_simulated): try: return { SYMBOL: position.symbol, SIDE: position.side.value, CONTRACT: str(position.symbol_contract), AMOUNT: position.size, VALUE: position.value, MARKET: position.currency if position.symbol_contract.is_inverse_contract() else position.market, ENTRY_PRICE: position.entry_price, LIQUIDATION_PRICE: position.liquidation_price, MARGIN: position.margin, UNREALIZED_PNL: position.unrealized_pnl, EXCHANGE: position.exchange_manager.exchange.name if position.exchange_manager else '', SIMULATED_OR_REAL: "Simulated" if is_simulated else "Real", } except Exception as err: logging.get_logger("TradingModel").exception(f"Error when dumping position {err}, position: {position}") return None def get_all_positions_data(): real, simulated = interfaces_util.get_all_positions() return _get_dumped_data( (position for position in real if not position.is_idle()), (position for position in simulated if not position.is_idle()), _dump_position ) def clear_exchanges_orders_history(simulated_only=False): _run_on_exchange_ids(trading_api.clear_orders_storage_history, simulated_only=simulated_only) return {"title": "Cleared orders history"} def clear_exchanges_trades_history(simulated_only=False): _run_on_exchange_ids(trading_api.clear_trades_storage_history, simulated_only=simulated_only) return {"title": "Cleared trades history"} def clear_exchanges_transactions_history(simulated_only=False): _run_on_exchange_ids(trading_api.clear_transactions_storage_history, simulated_only=simulated_only) return {"title": "Cleared transactions history"} def clear_exchanges_portfolio_history(simulated_only=False, simulated_portfolio=None): # apply updated simulated portfolio to init new historical values on this new portfolio simulated_portfolio = simulated_portfolio or \ interfaces_util.get_edited_config(dict_only=True).get(commons_constants.CONFIG_SIMULATOR, {}).get( commons_constants.CONFIG_STARTING_PORTFOLIO, None) if simulated_portfolio: _sync_run_on_exchange_ids(trading_api.set_simulated_portfolio_initial_config, simulated_only=simulated_only, portfolio_content=simulated_portfolio) _run_on_exchange_ids(trading_api.clear_portfolio_storage_history, simulated_only=simulated_only) return {"title": "Cleared portfolio history"} async def _async_run_on_exchange_ids(coro, exchange_ids, simulated_only, **kwargs): for exchange_manager in trading_api.get_exchange_managers_from_exchange_ids(exchange_ids): if (not simulated_only or trading_api.is_trader_simulated(exchange_manager)) \ and not trading_api.get_is_backtesting(exchange_manager): await coro(exchange_manager, **kwargs) def _run_on_exchange_ids(coro, simulated_only=False, **kwargs): interfaces_util.run_in_bot_main_loop( _async_run_on_exchange_ids(coro, trading_api.get_exchange_ids(), simulated_only, **kwargs) ) def _sync_run_on_exchange_ids(func, simulated_only=False, **kwargs): for exchange_manager in trading_api.get_exchange_managers_from_exchange_ids(trading_api.get_exchange_ids()): if (not simulated_only or trading_api.is_trader_simulated(exchange_manager)) \ and not trading_api.get_is_backtesting(exchange_manager): func(exchange_manager, **kwargs) ================================================ FILE: Services/Interfaces/web_interface/models/web_interface_tab.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. class WebInterfaceTab: def __init__( self, identifier, route, display_name, location, requires_open_source_package=False ): self.identifier = identifier self.route = route self.display_name = display_name self.location = location self.requires_open_source_package = requires_open_source_package def is_available(self, has_open_source_package): if not self.requires_open_source_package: # is available in general return True if self.requires_open_source_package and has_open_source_package: # is available if has_open_source_package return True # is not available return False ================================================ FILE: Services/Interfaces/web_interface/plugins/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from . import abstract_plugin from . import plugin_management from tentacles.Services.Interfaces.web_interface.plugins.abstract_plugin import ( AbstractWebInterfacePlugin, ) from tentacles.Services.Interfaces.web_interface.plugins.plugin_management import ( register_all_plugins, ) __all__ = [ "AbstractWebInterfacePlugin", "register_all_plugins", ] ================================================ FILE: Services/Interfaces/web_interface/plugins/abstract_plugin.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask import os import octobot_commons.enums as commons_enums import octobot_commons.logging as logging import octobot_commons.tentacles_management as tentacles_management import octobot_tentacles_manager.api import octobot_services.interfaces.util as interfaces_util class AbstractWebInterfacePlugin(tentacles_management.AbstractTentacle): USER_INPUT_TENTACLE_TYPE = commons_enums.UserInputTentacleTypes.WEB_PLUGIN NAME = None URL_PREFIX = None PLUGIN_ROOT_FOLDER = None TEMPLATE_FOLDER_NAME = "templates" STATIC_FOLDER_NAME = "static" ADDITIONAL_KWARGS = {} def __init__(self, name, url_prefix, plugin_folder, template_folder, static_folder, **kwargs): super().__init__() self.name = name self.url_prefix = url_prefix self.plugin_folder = plugin_folder self.template_folder = os.path.join(plugin_folder, template_folder) if plugin_folder else None self.static_folder = os.path.join(plugin_folder, static_folder) if plugin_folder else None self.kwargs = kwargs self.blueprint = None self.logger = logging.get_logger(self.name) @classmethod def get_name(cls): return cls.__name__ def register_routes(self): raise NotImplementedError("register_routes is not implemented") def get_tabs(self): """ Override if tabs are to be registered from this plugin :return: """ return [] @classmethod def init_user_inputs_from_class(cls, inputs: dict) -> None: """ Override if user inputs are required for this plugin """ @classmethod def is_configurable(cls): """ Override if the tentacle is allowed to be configured """ return False def blueprint_factory(self): self.blueprint = flask.Blueprint( self.name, self.name, url_prefix=self.url_prefix, template_folder=self.template_folder, static_folder=self.static_folder, ** self.kwargs ) return self.blueprint @classmethod def factory(cls, **kwargs): if cls.NAME is None: raise RuntimeError(f"{cls.__name__}.NAME mush be set") return cls( cls.NAME, cls.URL_PREFIX or f"/{cls.NAME}", cls.PLUGIN_ROOT_FOLDER, cls.TEMPLATE_FOLDER_NAME, cls.STATIC_FOLDER_NAME, **{**cls.ADDITIONAL_KWARGS, **kwargs} ) def register(self, server_instance): self.blueprint_factory() self.register_routes() server_instance.register_blueprint(self.blueprint) self.logger.debug(f"Registered {self.name} plugin") @classmethod def get_tentacle_config(cls, tentacles_setup_config=None): return octobot_tentacles_manager.api.get_tentacle_config( tentacles_setup_config or interfaces_util.get_edited_tentacles_config(), cls ) def __str__(self): return f"name: {self.name} url_prefix: {self.url_prefix} " \ f"template_folder: {self.template_folder} static_folder: {self.static_folder}" \ f"kwargs: {self.kwargs}" ================================================ FILE: Services/Interfaces/web_interface/plugins/plugin_management.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os.path import octobot_commons.tentacles_management as tentacles_management import octobot_commons.logging as logging import tentacles.Services.Interfaces.web_interface.plugins as plugins def register_all_plugins(server_instance, already_registered_plugins, **kwargs) -> list: registered_plugins = [] already_registered_plugins_by_classes = { plugin.__class__: plugin for plugin in already_registered_plugins } for plugin_class in _get_all_plugins(): try: can_use_plugin = True # flask blueprints can't be be unregistered: reuse them when already registered if plugin_class in already_registered_plugins_by_classes: plugin = already_registered_plugins_by_classes[plugin_class] else: plugin = plugin_class.factory(**kwargs) can_use_plugin = os.path.exists(plugin.plugin_folder) if can_use_plugin: plugin.register(server_instance) if can_use_plugin: registered_plugins.append(plugin) except Exception as e: logging.get_logger("WebInterfacePluggingRegistration").exception( e, True, f"Error when registering {plugin_class.__name__} plugin: {e}" ) return registered_plugins def _get_all_plugins() -> list: return tentacles_management.get_all_classes_from_parent(plugins.AbstractWebInterfacePlugin) ================================================ FILE: Services/Interfaces/web_interface/security.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import datetime import flask import werkzeug.http as werk_http import urllib.parse as url_parse CACHE_CONTROL_KEY = 'Cache-Control' def register_responses_extra_header(flask_app, high_security_level): # prepare extra response headers, see after_request response_extra_headers = _prepare_response_extra_headers(high_security_level) no_cache_headers = { CACHE_CONTROL_KEY: 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', 'Last-Modified': werk_http.http_date(datetime.datetime.now()), } @flask_app.after_request def after_request(response): if CACHE_CONTROL_KEY not in response.headers: response.headers.extend(no_cache_headers) response.headers.extend(response_extra_headers) return response def _prepare_response_extra_headers(include_security_headers): response_extra_headers = { # uncomment to completely disable client caching (js and css files etc) # 'Cache-Control': 'no-cache, no-store, must-revalidate', # 'Pragma': 'no-cache', # 'Expires': '0', # 'Last-Modified': werk_http.http_date(datetime.now()), } if include_security_headers: response_security_headers = { # X-Frame-Options: page can only be shown in an iframe of the same site 'X-Frame-Options': 'SAMEORIGIN', # ensure all app communication is sent over HTTPS 'Strict-Transport-Security': 'max-age=63072000; includeSubdomains', # instructs the browser not to override the response content type 'X-Content-Type-Options': 'nosniff', # enable browser cross-site scripting (XSS) filter 'X-XSS-Protection': '1; mode=block', } response_extra_headers.update(response_security_headers) return response_extra_headers def is_safe_url(target): ref_url = url_parse.urlparse(flask.request.host_url) test_url = url_parse.urlparse(url_parse.urljoin(flask.request.host_url, target)) return test_url.scheme in ('http', 'https') and \ ref_url.netloc == test_url.netloc ================================================ FILE: Services/Interfaces/web_interface/static/css/bootstrap-editable.css ================================================ /*! X-editable - v1.5.1 * In-place editing with Twitter Bootstrap, jQuery UI or pure jQuery * http://github.com/vitalets/x-editable * Copyright (c) 2013 Vitaliy Potapov; Licensed MIT */ .editableform { margin-bottom: 0; /* overwrites bootstrap margin */ } .editableform .control-group { margin-bottom: 0; /* overwrites bootstrap margin */ white-space: nowrap; /* prevent wrapping buttons on new line */ line-height: 20px; /* overwriting bootstrap line-height. See #133 */ } /* BS3 width:1005 for inputs breaks editable form in popup See: https://github.com/vitalets/x-editable/issues/393 */ .editableform .form-control { width: auto; } .editable-buttons { display: inline-block; /* should be inline to take effect of parent's white-space: nowrap */ vertical-align: top; margin-left: 7px; /* inline-block emulation for IE7*/ zoom: 1; *display: inline; } .editable-buttons.editable-buttons-bottom { display: block; margin-top: 7px; margin-left: 0; } .editable-input { vertical-align: top; display: inline-block; /* should be inline to take effect of parent's white-space: nowrap */ width: auto; /* bootstrap-responsive has width: 100% that breakes layout */ white-space: normal; /* reset white-space decalred in parent*/ /* display-inline emulation for IE7*/ zoom: 1; *display: inline; } .editable-buttons .editable-cancel { margin-left: 7px; } /*for jquery-ui buttons need set height to look more pretty*/ .editable-buttons button.ui-button-icon-only { height: 24px; width: 30px; } .editableform-loading { animation: spin 1s infinite linear; -webkit-animation: spin2 1s infinite linear; height: 25px; width: auto; min-width: 25px; } .editable-inline .editableform-loading { background-position: left 5px; } .editable-error-block { max-width: 300px; margin: 5px 0 0 0; width: auto; white-space: normal; } /*add padding for jquery ui*/ .editable-error-block.ui-state-error { padding: 3px; } .editable-error { color: red; } /* ---- For specific types ---- */ .editableform .editable-date { padding: 0; margin: 0; float: left; } /* move datepicker icon to center of add-on button. See https://github.com/vitalets/x-editable/issues/183 */ .editable-inline .add-on .icon-th { margin-top: 3px; margin-left: 1px; } /* checklist vertical alignment */ .editable-checklist label input[type="checkbox"], .editable-checklist label span { vertical-align: middle; margin: 0; } .editable-checklist label { white-space: nowrap; } /* set exact width of textarea to fit buttons toolbar */ .editable-wysihtml5 { width: 566px; height: 250px; } /* clear button shown as link in date inputs */ .editable-clear { clear: both; font-size: 0.9em; text-decoration: none; text-align: right; } /* IOS-style clear button for text inputs */ .editable-clear-x { display: block; width: 13px; height: 13px; position: absolute; opacity: 0.6; z-index: 100; top: 50%; right: 6px; margin-top: -6px; } .editable-clear-x:hover { opacity: 1; } .editable-pre-wrapped { white-space: pre-wrap; } .editable-container.editable-popup { max-width: none !important; /* without this rule poshytip/tooltip does not stretch */ } .editable-container.popover { width: auto; /* without this rule popover does not stretch */ } .editable-container.editable-inline { display: inline-block; vertical-align: middle; width: auto; /* inline-block emulation for IE7*/ zoom: 1; *display: inline; } .editable-container.ui-widget { font-size: inherit; /* jqueryui widget font 1.1em too big, overwrite it */ z-index: 9990; /* should be less than select2 dropdown z-index to close dropdown first when click */ } .editable-click, a.editable-click, a.editable-click:hover { text-decoration: none; border-bottom: dashed 1px #0088cc; } .editable-click.editable-disabled, a.editable-click.editable-disabled, a.editable-click.editable-disabled:hover { color: #585858; cursor: default; border-bottom: none; } .editable-empty, .editable-empty:hover, .editable-empty:focus{ font-style: italic; color: #DD1144; /* border-bottom: none; */ text-decoration: none; } .editable-unsaved { font-weight: bold; } .editable-unsaved:after { /* content: '*'*/ } .editable-bg-transition { -webkit-transition: background-color 1400ms ease-out; -moz-transition: background-color 1400ms ease-out; -o-transition: background-color 1400ms ease-out; -ms-transition: background-color 1400ms ease-out; transition: background-color 1400ms ease-out; } /*see https://github.com/vitalets/x-editable/issues/139 */ .form-horizontal .editable { padding-top: 5px; display:inline-block; } /*! * Datepicker for Bootstrap * * Copyright 2012 Stefan Petre * Improvements by Andrew Rowls * Licensed under the Apache License v2.0 * http://www.apache.org/licenses/LICENSE-2.0 * */ .datepicker { padding: 4px; -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; direction: ltr; /*.dow { border-top: 1px solid #ddd !important; }*/ } .datepicker-inline { width: 220px; } .datepicker.datepicker-rtl { direction: rtl; } .datepicker.datepicker-rtl table tr td span { float: right; } .datepicker-dropdown { top: 0; left: 0; } .datepicker-dropdown:before { content: ''; display: inline-block; border-left: 7px solid transparent; border-right: 7px solid transparent; border-bottom: 7px solid #ccc; border-bottom-color: rgba(0, 0, 0, 0.2); position: absolute; top: -7px; left: 6px; } .datepicker-dropdown:after { content: ''; display: inline-block; border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid #ffffff; position: absolute; top: -6px; left: 7px; } .datepicker > div { display: none; } .datepicker.days div.datepicker-days { display: block; } .datepicker.months div.datepicker-months { display: block; } .datepicker.years div.datepicker-years { display: block; } .datepicker table { margin: 0; } .datepicker td, .datepicker th { text-align: center; width: 20px; height: 20px; -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; border: none; } .table-striped .datepicker table tr td, .table-striped .datepicker table tr th { background-color: transparent; } .datepicker table tr td.day:hover { background: #eeeeee; cursor: pointer; } .datepicker table tr td.old, .datepicker table tr td.new { color: #999999; } .datepicker table tr td.disabled, .datepicker table tr td.disabled:hover { background: none; color: #999999; cursor: default; } .datepicker table tr td.today, .datepicker table tr td.today:hover, .datepicker table tr td.today.disabled, .datepicker table tr td.today.disabled:hover { background-color: #fde19a; background-image: -moz-linear-gradient(top, #fdd49a, #fdf59a); background-image: -ms-linear-gradient(top, #fdd49a, #fdf59a); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#fdd49a), to(#fdf59a)); background-image: -webkit-linear-gradient(top, #fdd49a, #fdf59a); background-image: -o-linear-gradient(top, #fdd49a, #fdf59a); background-image: linear-gradient(top, #fdd49a, #fdf59a); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fdd49a', endColorstr='#fdf59a', GradientType=0); border-color: #fdf59a #fdf59a #fbed50; border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); color: #000; } .datepicker table tr td.today:hover, .datepicker table tr td.today:hover:hover, .datepicker table tr td.today.disabled:hover, .datepicker table tr td.today.disabled:hover:hover, .datepicker table tr td.today:active, .datepicker table tr td.today:hover:active, .datepicker table tr td.today.disabled:active, .datepicker table tr td.today.disabled:hover:active, .datepicker table tr td.today.active, .datepicker table tr td.today:hover.active, .datepicker table tr td.today.disabled.active, .datepicker table tr td.today.disabled:hover.active, .datepicker table tr td.today.disabled, .datepicker table tr td.today:hover.disabled, .datepicker table tr td.today.disabled.disabled, .datepicker table tr td.today.disabled:hover.disabled, .datepicker table tr td.today[disabled], .datepicker table tr td.today:hover[disabled], .datepicker table tr td.today.disabled[disabled], .datepicker table tr td.today.disabled:hover[disabled] { background-color: #fdf59a; } .datepicker table tr td.today:active, .datepicker table tr td.today:hover:active, .datepicker table tr td.today.disabled:active, .datepicker table tr td.today.disabled:hover:active, .datepicker table tr td.today.active, .datepicker table tr td.today:hover.active, .datepicker table tr td.today.disabled.active, .datepicker table tr td.today.disabled:hover.active { background-color: #fbf069 \9; } .datepicker table tr td.today:hover:hover { color: #000; } .datepicker table tr td.today.active:hover { color: #fff; } .datepicker table tr td.range, .datepicker table tr td.range:hover, .datepicker table tr td.range.disabled, .datepicker table tr td.range.disabled:hover { background: #eeeeee; -webkit-border-radius: 0; -moz-border-radius: 0; border-radius: 0; } .datepicker table tr td.range.today, .datepicker table tr td.range.today:hover, .datepicker table tr td.range.today.disabled, .datepicker table tr td.range.today.disabled:hover { background-color: #f3d17a; background-image: -moz-linear-gradient(top, #f3c17a, #f3e97a); background-image: -ms-linear-gradient(top, #f3c17a, #f3e97a); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f3c17a), to(#f3e97a)); background-image: -webkit-linear-gradient(top, #f3c17a, #f3e97a); background-image: -o-linear-gradient(top, #f3c17a, #f3e97a); background-image: linear-gradient(top, #f3c17a, #f3e97a); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#f3c17a', endColorstr='#f3e97a', GradientType=0); border-color: #f3e97a #f3e97a #edde34; border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); -webkit-border-radius: 0; -moz-border-radius: 0; border-radius: 0; } .datepicker table tr td.range.today:hover, .datepicker table tr td.range.today:hover:hover, .datepicker table tr td.range.today.disabled:hover, .datepicker table tr td.range.today.disabled:hover:hover, .datepicker table tr td.range.today:active, .datepicker table tr td.range.today:hover:active, .datepicker table tr td.range.today.disabled:active, .datepicker table tr td.range.today.disabled:hover:active, .datepicker table tr td.range.today.active, .datepicker table tr td.range.today:hover.active, .datepicker table tr td.range.today.disabled.active, .datepicker table tr td.range.today.disabled:hover.active, .datepicker table tr td.range.today.disabled, .datepicker table tr td.range.today:hover.disabled, .datepicker table tr td.range.today.disabled.disabled, .datepicker table tr td.range.today.disabled:hover.disabled, .datepicker table tr td.range.today[disabled], .datepicker table tr td.range.today:hover[disabled], .datepicker table tr td.range.today.disabled[disabled], .datepicker table tr td.range.today.disabled:hover[disabled] { background-color: #f3e97a; } .datepicker table tr td.range.today:active, .datepicker table tr td.range.today:hover:active, .datepicker table tr td.range.today.disabled:active, .datepicker table tr td.range.today.disabled:hover:active, .datepicker table tr td.range.today.active, .datepicker table tr td.range.today:hover.active, .datepicker table tr td.range.today.disabled.active, .datepicker table tr td.range.today.disabled:hover.active { background-color: #efe24b \9; } .datepicker table tr td.selected, .datepicker table tr td.selected:hover, .datepicker table tr td.selected.disabled, .datepicker table tr td.selected.disabled:hover { background-color: #9e9e9e; background-image: -moz-linear-gradient(top, #b3b3b3, #808080); background-image: -ms-linear-gradient(top, #b3b3b3, #808080); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#b3b3b3), to(#808080)); background-image: -webkit-linear-gradient(top, #b3b3b3, #808080); background-image: -o-linear-gradient(top, #b3b3b3, #808080); background-image: linear-gradient(top, #b3b3b3, #808080); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#b3b3b3', endColorstr='#808080', GradientType=0); border-color: #808080 #808080 #595959; border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); color: #fff; text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); } .datepicker table tr td.selected:hover, .datepicker table tr td.selected:hover:hover, .datepicker table tr td.selected.disabled:hover, .datepicker table tr td.selected.disabled:hover:hover, .datepicker table tr td.selected:active, .datepicker table tr td.selected:hover:active, .datepicker table tr td.selected.disabled:active, .datepicker table tr td.selected.disabled:hover:active, .datepicker table tr td.selected.active, .datepicker table tr td.selected:hover.active, .datepicker table tr td.selected.disabled.active, .datepicker table tr td.selected.disabled:hover.active, .datepicker table tr td.selected.disabled, .datepicker table tr td.selected:hover.disabled, .datepicker table tr td.selected.disabled.disabled, .datepicker table tr td.selected.disabled:hover.disabled, .datepicker table tr td.selected[disabled], .datepicker table tr td.selected:hover[disabled], .datepicker table tr td.selected.disabled[disabled], .datepicker table tr td.selected.disabled:hover[disabled] { background-color: #808080; } .datepicker table tr td.selected:active, .datepicker table tr td.selected:hover:active, .datepicker table tr td.selected.disabled:active, .datepicker table tr td.selected.disabled:hover:active, .datepicker table tr td.selected.active, .datepicker table tr td.selected:hover.active, .datepicker table tr td.selected.disabled.active, .datepicker table tr td.selected.disabled:hover.active { background-color: #666666 \9; } .datepicker table tr td.active, .datepicker table tr td.active:hover, .datepicker table tr td.active.disabled, .datepicker table tr td.active.disabled:hover { background-color: #006dcc; background-image: -moz-linear-gradient(top, #0088cc, #0044cc); background-image: -ms-linear-gradient(top, #0088cc, #0044cc); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); background-image: -o-linear-gradient(top, #0088cc, #0044cc); background-image: linear-gradient(top, #0088cc, #0044cc); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0); border-color: #0044cc #0044cc #002a80; border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); color: #fff; text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); } .datepicker table tr td.active:hover, .datepicker table tr td.active:hover:hover, .datepicker table tr td.active.disabled:hover, .datepicker table tr td.active.disabled:hover:hover, .datepicker table tr td.active:active, .datepicker table tr td.active:hover:active, .datepicker table tr td.active.disabled:active, .datepicker table tr td.active.disabled:hover:active, .datepicker table tr td.active.active, .datepicker table tr td.active:hover.active, .datepicker table tr td.active.disabled.active, .datepicker table tr td.active.disabled:hover.active, .datepicker table tr td.active.disabled, .datepicker table tr td.active:hover.disabled, .datepicker table tr td.active.disabled.disabled, .datepicker table tr td.active.disabled:hover.disabled, .datepicker table tr td.active[disabled], .datepicker table tr td.active:hover[disabled], .datepicker table tr td.active.disabled[disabled], .datepicker table tr td.active.disabled:hover[disabled] { background-color: #0044cc; } .datepicker table tr td.active:active, .datepicker table tr td.active:hover:active, .datepicker table tr td.active.disabled:active, .datepicker table tr td.active.disabled:hover:active, .datepicker table tr td.active.active, .datepicker table tr td.active:hover.active, .datepicker table tr td.active.disabled.active, .datepicker table tr td.active.disabled:hover.active { background-color: #003399 \9; } .datepicker table tr td span { display: block; width: 23%; height: 54px; line-height: 54px; float: left; margin: 1%; cursor: pointer; -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; } .datepicker table tr td span:hover { background: #eeeeee; } .datepicker table tr td span.disabled, .datepicker table tr td span.disabled:hover { background: none; color: #999999; cursor: default; } .datepicker table tr td span.active, .datepicker table tr td span.active:hover, .datepicker table tr td span.active.disabled, .datepicker table tr td span.active.disabled:hover { background-color: #006dcc; background-image: -moz-linear-gradient(top, #0088cc, #0044cc); background-image: -ms-linear-gradient(top, #0088cc, #0044cc); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); background-image: -o-linear-gradient(top, #0088cc, #0044cc); background-image: linear-gradient(top, #0088cc, #0044cc); background-repeat: repeat-x; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0); border-color: #0044cc #0044cc #002a80; border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); filter: progid:DXImageTransform.Microsoft.gradient(enabled=false); color: #fff; text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); } .datepicker table tr td span.active:hover, .datepicker table tr td span.active:hover:hover, .datepicker table tr td span.active.disabled:hover, .datepicker table tr td span.active.disabled:hover:hover, .datepicker table tr td span.active:active, .datepicker table tr td span.active:hover:active, .datepicker table tr td span.active.disabled:active, .datepicker table tr td span.active.disabled:hover:active, .datepicker table tr td span.active.active, .datepicker table tr td span.active:hover.active, .datepicker table tr td span.active.disabled.active, .datepicker table tr td span.active.disabled:hover.active, .datepicker table tr td span.active.disabled, .datepicker table tr td span.active:hover.disabled, .datepicker table tr td span.active.disabled.disabled, .datepicker table tr td span.active.disabled:hover.disabled, .datepicker table tr td span.active[disabled], .datepicker table tr td span.active:hover[disabled], .datepicker table tr td span.active.disabled[disabled], .datepicker table tr td span.active.disabled:hover[disabled] { background-color: #0044cc; } .datepicker table tr td span.active:active, .datepicker table tr td span.active:hover:active, .datepicker table tr td span.active.disabled:active, .datepicker table tr td span.active.disabled:hover:active, .datepicker table tr td span.active.active, .datepicker table tr td span.active:hover.active, .datepicker table tr td span.active.disabled.active, .datepicker table tr td span.active.disabled:hover.active { background-color: #003399 \9; } .datepicker table tr td span.old, .datepicker table tr td span.new { color: #999999; } .datepicker th.datepicker-switch { width: 145px; } .datepicker thead tr:first-child th, .datepicker tfoot tr th { cursor: pointer; } .datepicker thead tr:first-child th:hover, .datepicker tfoot tr th:hover { background: #eeeeee; } .datepicker .cw { font-size: 10px; width: 12px; padding: 0 2px 0 5px; vertical-align: middle; } .datepicker thead tr:first-child th.cw { cursor: default; background-color: transparent; } .input-append.date .add-on i, .input-prepend.date .add-on i { display: block; cursor: pointer; width: 16px; height: 16px; } .input-daterange input { text-align: center; } .input-daterange input:first-child { -webkit-border-radius: 3px 0 0 3px; -moz-border-radius: 3px 0 0 3px; border-radius: 3px 0 0 3px; } .input-daterange input:last-child { -webkit-border-radius: 0 3px 3px 0; -moz-border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0; } .input-daterange .add-on { display: inline-block; width: auto; min-width: 16px; height: 18px; padding: 4px 5px; font-weight: normal; line-height: 18px; text-align: center; text-shadow: 0 1px 0 #ffffff; vertical-align: middle; background-color: #eeeeee; border: 1px solid #ccc; margin-left: -5px; margin-right: -5px; } ================================================ FILE: Services/Interfaces/web_interface/static/css/components/configuration.css ================================================ li.nav-item { padding-right: 5px; } .navbar .nav-item .nav-link.active { font-weight: 400; } ================================================ FILE: Services/Interfaces/web_interface/static/css/layout.css ================================================ /* Nav bar disabling */ a.disabled { /* Make the disabled links grayish*/ color: gray; /* And disable the pointer events */ pointer-events: none; } .icon-black { color: black; } .danger-color{ background-color: #ff4444; } .danger-color-dark{ background-color: #CC0000; } .warning-color{ background-color: #ffbb33; } .warning-color-dark{ background-color: #FF8800; } .success-color{ background-color: #00C851; } .success-color-dark{ background-color: #007E33; } .info-color{ background-color: #33b5e5; } .info-color-dark{ background-color: #0099CC; } ================================================ FILE: Services/Interfaces/web_interface/static/css/style.css ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ /* variables */ /* commons */ :root { --mdb-body-font-family: DM Sans, sans-serif; --local-secondary-bg-text-color: #0f1237; /* same as dark mdb-primary */ --local-price-chart-sell-color: #F65A33; --local-price-chart-stop-color: #FFA500; } :root[data-mdb-theme=light] { --mdb-primary: #0f1237; --mdb-primary-rgb: 15, 18, 55; --mdb-primary-500: #5ba0cc; --mdb-primary-500-rgb: 91, 106, 204; --mdb-secondary: #85d6d7; --mdb-secondary-rgb: 133, 214, 215; --mdb-secondary-500: #65e7cf; --mdb-secondary-500-rgb: 101, 231, 207; --mdb-bg: #f3f6f8; --mdb-bg-500: #85d6d7; /* text */ --mdb-emphasis-color-rgb: var(--mdb-primary-rgb); --mdb-primary-text-emphasis: var(--mdb-primary); --mdb-heading-color: var(--mdb-primary); /* links */ --mdb-link-color: var(--mdb-secondary); --mdb-link-color-rgb: var(--mdb-secondary-rgb); --mdb-link-hover-color: var(--mdb-secondary-500); --mdb-link-hover-color-rgb: var(--mdb-secondary-500-rgb); /* navbar */ --mdb-navbar-color: var(--mdb-primary); --mdb-nav-link-color: var(--mdb-primary); /* body */ --mdb-body-color: var(--mdb-primary); --mdb-body-color-rgb: var(--mdb-primary-rgb); --mdb-body-bg: var(--mdb-bg); --mdb-body-bg-rgb: var(--mdb-bg-rgb); /* button */ --mdb-button-color: var(--mdb-primary); --mdb-outline-button-color: var(--mdb-primary); /* table */ --mdb-table-headings-bg: var(--mdb-secondary); --mdb-table-headings-color: var(--mdb-primary); /* cards */ --mdb-card-modified-border-color: var(--mdb-orange); --mdb-card-container-modified-border-color: var(--mdb-orange); --mdb-card-status-color: var(--mdb-bg); --mdb-card-very-long-border-color: var(--mdb-secondary-500); --mdb-card-long-border-color: var(--mdb-secondary); --mdb-card-short-border-color: var(--mdb-orange); --mdb-card-very-short-border-color: var(--mdb-red); --mdb-card-bg: var(--mdb-bg); --mdb-card-color: var(--mdb-primary); --mdb-surface-bg: var(--mdb-bg); /* navbar */ --mdb-navbar-bg: var(--mdb-bg); --mdb-navbar-brand-color: var(--mdb-primary); /* local */ /* config cards */ --local-config-card--border-width: 0px; /* price charts */ --local-price-chart-buy-color: #6cb596; --local-price-chart-candle-sell-color: var(--local-price-chart-sell-color); --local-price-chart-candle-buy-color: #6cb596; } :root[data-mdb-theme=dark] { --mdb-primary: #f3f6f8; --mdb-primary-rgb: 243, 246, 248; --mdb-secondary: #85d6d7; --mdb-secondary-rgb: 133, 214, 215; --mdb-secondary-500: #65e7cf; --mdb-secondary-500-rgb: 101, 231, 207; --mdb-bg: #0f1237; --mdb-bg-rgb: 15, 18, 55; --mdb-bg-500: #19283e; /* text */ --mdb-emphasis-color-rgb: var(--mdb-primary-rgb); --mdb-primary-text-emphasis: var(--mdb-primary); --mdb-heading-color: var(--mdb-primary); /* links */ --mdb-link-color: var(--mdb-secondary); --mdb-link-color-rgb: var(--mdb-secondary-rgb); --mdb-link-hover-color: var(--mdb-secondary-500); --mdb-link-hover-color-rgb: var(--mdb-secondary-500-rgb); /* navbar */ --mdb-navbar-color: var(--mdb-primary); --mdb-nav-link-color: var(--mdb-primary); /* body */ --mdb-body-color: var(--mdb-primary); --mdb-body-color-rgb: var(--mdb-primary-rgb); --mdb-body-bg: var(--mdb-bg); --mdb-body-bg-rgb: var(--mdb-bg-rgb); /* button */ --mdb-button-color: var(--mdb-bg); --mdb-outline-button-color: var(--mdb-primary); /* table */ --mdb-table-headings-bg: var(--mdb-bg); --mdb-table-headings-color: var(--mdb-secondary); /* cards */ --mdb-card-modified-border-color: var(--mdb-orange); --mdb-card-container-modified-border-color: var(--mdb-orange); --mdb-card-status-color: var(--mdb-bg); --mdb-card-very-long-border-color: var(--mdb-secondary-500); --mdb-card-long-border-color: var(--mdb-secondary); --mdb-card-short-border-color: var(--mdb-orange); --mdb-card-very-short-border-color: var(--mdb-red); --mdb-card-bg: var(--mdb-secondary); --mdb-card-color: var(--mdb-primary); --mdb-surface-bg: var(--mdb-bg-500); /* navbar */ --mdb-navbar-bg: var(--mdb-bg); --mdb-navbar-brand-color: var(--mdb-primary); /* local */ /* config cards */ --local-config-card--border-width: 1px; /* price charts */ --local-price-chart-buy-color: #6cb596; --local-price-chart-candle-sell-color: var(--local-price-chart-sell-color); --local-price-chart-candle-buy-color: #65e7cf; } /* Fix incompatibility with bootstrap 4 */ a:hover { color: var(--mdb-link-hover-color); } /* Fix declarations */ .nav-link, .navbar-brand, .card-body, .card-footer, .card-header, .select2-results__option, .filter-option { color: var(--mdb-primary); } .dropdown, .datepicker, .select2-container--default .select2-selection--multiple, .select2-results__option { background-color: var(--mdb-bg); } .dropdown.bootstrap-select, .datepicker, .select2-selection select2-selection--multiple { border: solid black 1px; border-radius: 4px; } .nav-tabs .nav-link { --mdb-nav-tabs-link-active-color: var(--mdb-secondary); --mdb-nav-tabs-link-active-border-color: var(--mdb-secondary); } .btn-primary { --mdb-btn-bg: var(--mdb-secondary); --mdb-btn-color: var(--mdb-button-color); --mdb-btn-box-shadow: 0 4px 9px -4px var(--mdb-secondary-500); --mdb-btn-hover-bg: var(--mdb-secondary-500); --mdb-btn-focus-bg: var(--mdb-secondary-500); --mdb-btn-active-bg: var(--mdb-secondary-500); --mdb-btn-disabled-bg: var(--mdb-secondary); } .btn-outline-primary { /*--mdb-btn-bg: var(--mdb-secondary);*/ --mdb-btn-color: var(--mdb-outline-button-color) !important; --mdb-btn-box-shadow: 0 4px 9px -4px var(--mdb-secondary); --mdb-btn-hover-color: var(--mdb-primary); --mdb-btn-hover-border-color: var(--mdb-secondary); --mdb-btn-focus-shadow-rgb: var(--mdb-secondary-rgb); --mdb-btn-hover-bg: var(--mdb-secondary); --mdb-btn-focus-bg: var(--mdb-secondary); --mdb-btn-active-bg: var(--mdb-secondary); --mdb-btn-focus-color: var(--mdb-secondary); --mdb-btn-active-color: var(--mdb-secondary); --mdb-btn-active-border-color: var(--mdb-secondary); --mdb-btn-outline-border-color: var(--mdb-secondary); --mdb-btn-outline-focus-border-color: var(--mdb-secondary); --mdb-btn-outline-hover-border-color: var(--mdb-secondary); } .editable-cancel { --mdb-btn-bg: var(--mdb-bg); --mdb-btn-color: var(--mdb-primary); --mdb-btn-box-shadow: 0 4px 9px -4px var(--mdb-secondary); --mdb-btn-hover-color: var(--mdb-primary); --mdb-btn-hover-border-color: var(--danger); --mdb-btn-focus-shadow-rgb: var(--mdb-secondary-rgb); --mdb-btn-hover-bg: var(--danger); --mdb-btn-focus-bg: var(--danger); --mdb-btn-active-bg: var(--danger); --mdb-btn-focus-color: var(--mdb-secondary); --mdb-btn-active-color: var(--mdb-secondary); --mdb-btn-active-border-color: var(--danger); --mdb-btn-outline-border-color: var(--mdb-secondary); --mdb-btn-outline-focus-border-color: var(--mdb-secondary); --mdb-btn-outline-hover-border-color: var(--mdb-secondary); } .table th, th { color: var(--mdb-table-headings-color); } .table { --mdb-table-color: var(--mdb-primary); --mdb-table-striped-color: var(--mdb-primary); } .table td, .table th { border-top: none; } /* toast */ #toast-container>.toast-warning { background-image: none !important; } #toast-container>.toast-error { background-image: none !important; } #toast-container>.toast-success { background-image: none !important; } #toast-container>.toast-info { background-image: none !important; } .bg-light, .btn-light { background-color: transparent !important; } .select2-selection__choice { background-color: var(--mdb-secondary) !important; color: var(--mdb-button-color) !important; } .select2-container--default .select2-results__option--selected { background-color: var(--mdb-secondary) !important; color: var(--mdb-button-color) !important; } /* modal */ .close { color: var(--mdb-primary); /* ensure close button is visible in both themes */ } .close:hover { color: var(--mdb-secondary); /* ensure close button is visible in both themes */ } .fs-1 { font-size: 4rem !important; } /* plotly */ .gtitle, .xtitle, .xtick text { fill: var(--mdb-primary) !important; } /*override mdb font-weight */ strong { font-weight: bolder; } .editable { display: inline; } .bg-warning-dark { background-color: #FF8800 !important; } .toast-top-right { top: 4rem; } #toast-container>div { color: var(--mdb-primary); } .quote { border-left: 5px solid #1565C0; } html, body { height: 100%; min-height: 100%; } @media screen and (min-width: 768px) { /*equivalent of bootstrap -md filter*/ .w-md-75 { width: 75% !important; } } @media screen and (min-width: 992px) { /*equivalent of bootstrap -lg filter*/ .w-lg-50 { width: 50% !important; } } .login_box { max-width: 30rem; } .brand-logo { height: 100%; width: 100%; max-height: 2.5rem; max-width: 2.5rem; } .navbar-logo { height: 100%; width: 100%; max-height: 3rem; max-width: 3rem; } .profile-overview-values { font-weight: bold; } .profile-overview-explanation { font-weight: inherit; } .profile-overview { border: 2px solid black; } .vertically-aligned { justify-content: space-between; display: flex; flex-direction: column; height: 100%; } .profile-overview:hover { animation: all 0.2s ease-in forwards; border-bottom: 2px solid white; border-right: 2px solid white; } .profile-overview-selected { border-bottom: 2px solid var(--mdb-secondary-500); border-right: 2px solid var(--mdb-secondary-500); } .profile-overview-image { max-height: 120px; max-width: 120px; } footer.page-footer { color: var(--mdb-primary-text-emphasis); background-color: var(--mdb-navbar-bg); box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05); } .navbar { background-color: var(--mdb-navbar-bg); } .navbar .navbar-nav .nav-item:hover { animation: all 0.2s ease-in forwards; border-bottom: 2px solid var(--mdb-secondary); } .navbar .navbar-nav .nav-item.active { border-bottom: 2px solid var(--mdb-secondary); } .dropdown-toggle::after { margin-top: auto; margin-bottom: auto; transform: rotate(180deg); } .dropdown-toggle.collapsed::after { margin-top: auto; margin-bottom: auto; transform: none; } .sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 10; /* Behind the navbar */ padding: 48px 0 0; /* Height of navbar */ box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); } @supports ((position: -webkit-sticky) or (position: sticky)) { .sidebar-sticky { position: -webkit-sticky; position: sticky; } } .sidebar-sticky { position: relative; top: 0; height: calc(100vh - 48px); padding-top: .5rem; overflow-x: hidden; overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ } .sidebar .nav-title { margin-left: 2rem; } .separator { border-bottom: 1px solid var(--mdb-secondary); } .btn.btn-sm.rounded-circle { padding-left: 0.64rem; padding-right: 0.64rem; border-radius: 50%; } .font-size-90 { font-size: 90% !important; } /* Cards */ .card-deck { display: flex; justify-content: flex-start; flex-flow: row wrap; align-items: stretch; } .card { display: block; flex-basis: 33.3%; . rounded-bottom !important; } .card.profile-card { flex-basis: inherit !important; } .config-card { border: var(--local-config-card--border-width) solid rgba(var(--mdb-primary-rgb), 0.3); } .community-bot-stats-label { font-weight: bold; font-size: x-large; color: var(--mdb-primary); } .community-bot-stats { font-size: large; color: var(--mdb-button-color); background-color: var(--mdb-secondary-500); } .package_row_image { max-height: 4rem; } .table td.centered { text-align: center; vertical-align: middle; } .tentacle_package_action { font-size: 1.5rem; } .blurred { filter: blur(0.1rem); } .card, .block-card { flex-basis: 100% !important; } @media screen and (min-width: 768px) { /*equivalent of bootstrap -md filter*/ .card, .block-card { flex-basis: 50% !important; } } .bg-darker { background-color: #121212; } .text-danger-darker { color: #C62828; } .disabled-text { color: grey; } .medium-size { max-width: 18rem; min-width: 12rem; justify-content: space-between; display: flex; } .medium-size .card-body { flex: 0 0 auto; } .small-size { max-width: 18rem; min-width: 12rem; min-height: 14rem; } .very-small-size { max-width: 3rem; min-width: 2rem; max-height: 3rem; min-height: 2rem; } .cloud-logo { max-width: 6rem; min-width: 4rem; max-height: 6rem; min-height: 4rem; } .cloud-logo-2x { max-height: 8rem; } .cloud-logo-4x { max-height: 24rem; } .feature-margin { margin-top: 10rem; } .img-feature { max-height: 20rem; margin: auto; display: block; } /* Theme */ /* Elegant */ .card-body.candle-graph { height: 30.625rem; background-color: inherit; } .select2-results__group{ font-weight: bold; text-align: center; border-top: 1px solid #e9ecef; font-size: .875rem; } .small-image { max-height: 128px; max-width: 128px; } .help-section { padding-bottom: 0.4rem; } .fa-1_5x { font-size: 1.5em; } .fa.fa-spinner { font-size: 1.4em; } .fa.fa-spinner.fa-2xl { font-size: 32px; } span.large-editable div.editable-input { width: 100%; } a:hover.external-link { font-weight: inherit; } a:hover.hover_anim { font-weight: inherit; box-shadow: 0.1rem 0.1rem; } a:hover.badge { font-weight: 700; } .card a:link.button { color: inherit; } .interface-screen { filter: brightness(80%); } .editable-input, .editable-submit, .editable-cancel { position: relative; z-index: 2; } .bg-rating-1 { background-color: rgba(1, 181, 116, 0.6); } .bg-rating-2 { background-color: rgba(255, 181, 71, 0.6); } .bg-rating-3 { background-color: rgba(238, 93, 80, 0.6); } .exchange-logo { max-width: 85px; height: auto; } .pointer-cursor { cursor: pointer; } /* tables */ table.dataTable thead th, table.dataTable tfoot th { background: var(--mdb-table-headings-bg); font-weight: normal; } /* cards */ .card .deck-container-modified { border: 2px solid var(--mdb-card-container-modified-border-color); } .card .card-modified, .card .card-deck .card-modified { border: 2px solid var(--mdb-card-modified-border-color); } .card .card-deck .card-status-color { -webkit-transition: all 0.5s ease; -moz-transition: all 0.5s ease; -o-transition: all 0.5s ease; transition: all 0.5s ease; border: 5px solid var(--mdb-card-status-color); } .card .card-deck .card-very-long { border: 5px solid var(--mdb-card-very-long-border-color); } .card .card-deck .card-long { border: 5px solid var(--mdb-card-long-border-color); } .card .card-deck .card-short { border: 5px solid var(--mdb-card-short-border-color); } .card .card-deck .card-very-short { border: 5px solid var(--mdb-card-very-short-border-color); } /* introjs */ .introjs-tooltip { background-color: rgba(000, 0, 0, 0.9); } .introjs-tooltip-title { color: #fff; /* avoid using h1 color */ } /* markdown fixes */ pre code { font-size: inherit; color: var(--mdb-code-color); /* 'inherit' overridden for themes compatibility */ word-break: normal; } ================================================ FILE: Services/Interfaces/web_interface/static/css/w2ui_template.css ================================================ /** todo clean up **/ .w2ui-column-check { background: #fff !important; color: #131722 !important; } .w2ui-grid .w2ui-grid-body table .w2ui-col-number { background-color: unset !important; } .w2ui-grid-data-spacer, .w2ui-grid-data { border-bottom: unset !important; } .w2ui-empty-record *, .w2ui-empty-record { border: none !important; box-shadow:none !important; } .w2ui-btn.close-btn { padding: 24px 9px !important; padding-top: 24px !important; } .w2ui-btn { border-radius: 0 !important; background-color: #131722 !important; font-size: 1rem !important; text-transform: unset !important; padding: 8px 2rem !important; border-color: #fb3 !important; box-shadow: unset !important; color: #fb3 ! important; } .w2ui-btn-blue { color: #00c851 !important; border-color: #00c851 !important; } .w2ui-btn { margin: 0 !important; } input.w2ui-input, .w2ui-overlay *, .w2ui-overlay select, .w2ui-btn { background-color: #131722 !important; color: #b2b5be !important; border-color: #2a2e39 !important; background-image: unset !important; } .w2ui-record, .w2ui-btn { border: 1px solid var(--brdr) !important; } .w2ui-overlay tr:hover, .w2ui-grid-toolbar td:hover, .w2ui-grid-toolbar tr:hover, .w2ui-grid-toolbar table:hover, .w2ui-grid-toolbar tbody:hover, .w2ui-grid-toolbar table tbody tr:hover td, .w2ui-grid-toolbar table.dataTable tbody tr:hover td { -moz-box-shadow: none !important; -webkit-box-shadow: none !important; box-shadow: none !important; } .w2ui-grid .w2ui-grid-body { background-color: var(--bg); } ================================================ FILE: Services/Interfaces/web_interface/static/distributions/market_making/js/configuration.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const getAvailableCurrencies = () => { const currencies = new Set() exchangeSymbols.forEach(symbol => { const baseAndQuote = symbol.split("/"); currencies.add(baseAndQuote[0]); currencies.add(baseAndQuote[1]); }) return Array.from(currencies); } const fetchExchangeSymbols = async (exchange) => { const url = $("#traded-symbol-selector").data("update-url") const allSymbols = await async_send_and_interpret_bot_update( null, `${url}/${exchange}`, null, "GET" ) exchangeSymbols = allSymbols.filter(symbol => { // ignore non spot symbols return symbol.indexOf(":") === -1; }) } const saveConfig = async (saveUrl) => { try { validateConfig(); const updatedConfig = getConfigUpdate(); const resp = await async_send_and_interpret_bot_update(updatedConfig, saveUrl, null); create_alert("success", "Configuration saved", resp); refreshExchangeSelector() lastSavedConfig = updatedConfig configEditor.validate() } catch (error) { create_alert("error", "Impossible to save config", error) } } const updateSymbols = async (exchange) => { const previouslySelectedSymbol = getSelectedPair(); clearSymbolSelector(); await fetchExchangeSymbols(exchange); const currencies = getAvailableCurrencies(); refreshSymbolSelector(previouslySelectedSymbol); refreshPortfolioEditor(currencies); } const clearSymbolSelector = () => { $("#traded-symbol-selector").empty() } const refreshSymbolSelector = (previouslySelectedSymbol) => { const symbolSelector = $("#traded-symbol-selector"); let options = [] const profilePair = symbolSelector.data("selected-pair"); const selectedValue = previouslySelectedSymbol === null ? profilePair: previouslySelectedSymbol; options = options.concat(exchangeSymbols.sort().map((symbol) => { return new Option(symbol, symbol, false, symbol===selectedValue); })); clearSymbolSelector() symbolSelector.append(...options); } const refreshExchangeSelector = () => { const exchanges = getSelectableExchange(); const exchangeSelector = $("#main-exchange-selector"); const profileExchange = exchangeSelector.data("selected-exchange"); const selectedValue = exchangeSelector.val() === null ? profileExchange: exchangeSelector.val(); const options = exchanges.map((exchange) => { return new Option(exchange, exchange, false, exchange===selectedValue); }); if(selectedValue !== null && exchanges.indexOf(selectedValue) === -1){ // previously selected value is not available anymore: select 1st value by default options[0].selected = true; onSelectedExchange(options[0].value); } exchangeSelector.empty() exchangeSelector.append(...options); } const onSelectedExchange = async (exchange) => { if(typeof exchange === "string") { await updateSymbols(exchange); } } const refreshPortfolioEditor = (currencies) => { const editorDiv = $("#simulated-portfolio-editor"); let value = editorDiv.data("config"); if(typeof value === "undefined"){ return } const schema = editorDiv.data("schema"); if(simulatedPortfolioEditor !== undefined) { value = simulatedPortfolioEditor.getValue(); simulatedPortfolioEditor.destroy(); } value.forEach((val) => { if(currencies.indexOf(val.asset) === -1){ currencies.push(val.asset) } }) schema.items.properties.asset.enum = currencies.sort(); simulatedPortfolioEditor = new JSONEditor(editorDiv[0],{ schema: schema, startval: value, no_additional_properties: true, prompt_before_delete: true, disable_array_reorder: true, disable_array_delete: false, disable_array_delete_last_row: true, disable_array_delete_all_rows: true, disable_collapse: true, disable_edit_json: true, disable_properties: true, }) simulatedPortfolioEditor.on('ready', () => { readyEditors.portfolio = true initLastSavedConfig(); }) } const refreshTradingSimulatorEditor = () => { const editorDiv = $("#trading-simulator-editor"); let value = editorDiv.data("config"); if(typeof value === "undefined"){ return } const schema = editorDiv.data("schema"); if(tradingSimulatorEditor !== undefined) { value = tradingSimulatorEditor.getValue(); tradingSimulatorEditor.destroy(); } schema.options = { titleHidden: true } tradingSimulatorEditor = new JSONEditor(editorDiv[0],{ schema: schema, startval: value, disable_collapse: true, disable_edit_json: true, disable_properties: true, }) tradingSimulatorEditor.on('ready', () => { readyEditors.simulator = true initLastSavedConfig(); }) } const refreshExchangesEditor = () => { const editorDiv = $("#exchanges-editor"); let value = editorDiv.data("config"); if(typeof value === "undefined"){ return } const schema = editorDiv.data("schema"); if(exchangesEditor !== undefined) { exchangesEditor.destroy(); } schema.options = { titleHidden: true } const selectableExchanges = schema.items.properties.name.enum; value.forEach((val) => { if(selectableExchanges.indexOf(val.name) === -1){ selectableExchanges.push(val.name) } }) schema.id="exchangesConfig" exchangesEditor = new JSONEditor(editorDiv[0],{ schema: schema, startval: value, no_additional_properties: true, prompt_before_delete: true, disable_array_reorder: true, disable_array_delete: false, disable_array_delete_last_row: true, disable_array_delete_all_rows: true, disable_collapse: true, disable_edit_json: true, disable_properties: true, }) } const addCustomValidator = () => { // Custom validators must return an array of errors or an empty array if valid JSONEditor.defaults.custom_validators.push((schema, value, path) => { const errors = []; if (schema.id === "exchangesConfig" && path === "root") { const newNames = value.map(value => value.name); const duplicates = newNames.filter( (value, index) => newNames.indexOf(value) !== index && newNames.lastIndexOf(value) === index ); if (duplicates.length) { // Errors must be an object with `path`, `property`, and `message` errors.push({ path: path, property: '', message: `Each exchanges can only be listed once. Exchanges listed more than once: ${duplicates}.` }); } } if (schema.id === "tentacleConfig" && path === "root") { const referenceExchange = value.reference_exchange; if (referenceExchange !== undefined) { try { // in try catch in case getSelectableExchange is not yet available const listedExchanges = getSelectableExchange(); if (listedExchanges.concat(["local"]).indexOf(referenceExchange) === -1){ // Errors must be an object with `path`, `property`, and `message` errors.push({ path: path, property: 'reference_exchange', message: `Reference exchange must be listed in exchange configurations or equal to "local". Listed exchanges are ${listedExchanges.join(', ')}.` }); } if (referenceExchange === getSelectedExchange()){ // "local" must be used to use the same exchange to trade and as reference price errors.push({ path: path, property: 'reference_exchange', message: `Reference exchange must be set to "local" when equal to your selected exchange.` }); } } catch (err) { console.error(err) } } const minSpread = value.min_spread; const maxSpread = value.max_spread; if(minSpread !== undefined && maxSpread !== undefined && minSpread >= maxSpread){ errors.push({ path: path, property: 'max_spread', message: `Max spread % must be larger than Min spread %.` }); } } return errors; }); } const initSelectedExchange = async () => { refreshExchangeSelector(); await onSelectedExchange(getSelectedExchange()) } const getSelectableExchange = () => { if(exchangesEditor === undefined){ return [] } return exchangesEditor.getValue().map(value => value.name) } const getSelectedExchange = () => { return $("#main-exchange-selector").val() } const getSelectedPair = () => { return $("#traded-symbol-selector").val() } const getTradingModeName = () => { return $("#trading-mode-config-editor").data("trading-mode-name") } const registerEvents = () => { $("#main-exchange-selector").on( "change", () => onSelectedExchange(getSelectedExchange()) ) } const validateConfig = () => { [configEditor, tradingSimulatorEditor, simulatedPortfolioEditor, exchangesEditor].forEach((editor) => { if (editor === undefined) { throw "Editors are loading" } const errors = editor.validate(); if (errors.length) { throw JSON.stringify(errors.map( err => `${err.path.replace('root.', '')}: ${err.message}` ).join(", ")) } }); const exchange = getSelectedExchange() if(exchange === undefined || exchange === null || !exchange.length){ throw "No selected exchange" } const pair = getSelectedPair() if(pair === undefined || pair === null || !pair.length){ // can happen, don't prevent saving create_alert("error", "Action required", "Please select a trading pair to start your strategy"); } } const getConfigUpdate = () => { return { exchange: getSelectedExchange(), tradingPair: getSelectedPair(), tradingModeName: getTradingModeName(), tradingModeConfig: configEditor.getValue(), tradingSimulatorConfig: tradingSimulatorEditor.getValue(), simulatedPortfolioConfig: simulatedPortfolioEditor.getValue(), exchangesConfig: exchangesEditor.getValue(), } } const initLastSavedConfig = () => { if ( readyEditors.exchanges && readyEditors.simulator && readyEditors.portfolio && lastSavedConfig === undefined ) { lastSavedConfig = getConfigUpdate() } } const initUIWhenPossible = () => { exchangesEditor.on('ready', () => { initSelectedExchange(); registerEvents(); readyEditors.exchanges = true initLastSavedConfig(); }) $("[data-role=save]").on("click", (event) => { saveConfig($(event.currentTarget).data("update-url")) }) } const hasPendingUpdates = () => { if (tradingSimulatorEditor === undefined || simulatedPortfolioEditor === undefined || exchangesEditor === undefined || lastSavedConfig === undefined ) { return false; } return getValueChangedFromRef( getConfigUpdate(), lastSavedConfig, true ) } let exchangeSymbols = []; let tradingSimulatorEditor = undefined; let simulatedPortfolioEditor = undefined; let exchangesEditor = undefined; let lastSavedConfig = undefined const readyEditors = { exchanges: false, simulator: false, portfolio: false, } refreshExchangesEditor(); refreshTradingSimulatorEditor(); initUIWhenPossible(); addCustomValidator(); register_exit_confirm_function(hasPendingUpdates) startTutorialIfNecessary("mm:configuration"); }); ================================================ FILE: Services/Interfaces/web_interface/static/distributions/market_making/js/dashboard.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { startTutorialIfNecessary("mm:home"); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/backtesting_util.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function start_backtesting(request, update_url, success_callback=null){ const success = success_callback === null ? start_success_callback : success_callback; send_and_interpret_bot_update(request, update_url, null, success, start_error_callback); } function start_success_callback(updated_data, update_url, dom_root_element, msg, status){ $("#progess_bar_anim").css('width', 0+'%').attr("aria-valuenow", 0); create_alert("success", msg, ""); } function start_error_callback(updated_data, update_url, dom_root_element, result, status, error){ create_alert("error", result.responseText, ""); $(`#${backtestingMainProgressBar}`).hide(); lock_interface(false); } function lock_interface(lock=true){ let should_lock = lock; if(!should_lock){ lock_interface_callbacks.forEach(function (value) { if(value()){ should_lock = true; } }); } $('#startBacktesting').prop('disabled', should_lock); } function load_report(report, should_alert=False) { const reportDiv = $("#backtestingReport"); if (reportDiv.length) { const url = reportDiv.attr(update_url_attr); $.get(url, (data) => { if ("report" in data) { let error_message = ""; const globalReport = data["report"] const botReport = globalReport["bot_report"] report.show(); const profitabilities = []; const show_exchanges = Object.keys(botReport["profitability"]).length > 1; $.each(botReport["profitability"], function (exchange, profitability) { const exch = show_exchanges ? `${exchange}: ` : ""; profitabilities.push(`${exch}${profitability}`); }); let profitability = profitabilities.join(", "); const errors_count = globalReport["errors_count"]; if ("error" in globalReport || errors_count > 0) { error_message = "Warning: error(s) during backtesting"; if ("error" in globalReport) { error_message += " " + globalReport["error"]; } if (errors_count > 0) { error_message += " " + errors_count + " error(s)"; } error_message += ", more details in logs."; if (should_alert) { create_alert("error", error_message, ""); } $("#backtestingErrorsAlert").show(); } else { $("#backtestingErrorsAlert").hide(); } const symbol_reports = []; $.each(globalReport["symbol_report"], function (index, value) { $.each(value, function (symbol, profitability) { symbol_reports.push(`${symbol}: ${round_digits(profitability, 4)}%`); }); }); const all_profitability = symbol_reports.join(", "); $("#bProf").html(`${round_digits(profitability, 4)}% ${error_message}`); const avg_profitabilities = []; $.each(botReport["market_average_profitability"], function (exchange, market_average_profitability) { const exch = show_exchanges ? `${exchange}: ` : ""; avg_profitabilities.push(`${exch}${round_digits(market_average_profitability, 4)}%`); }); $("#maProf").html(avg_profitabilities.join(", ")); $("#refM").html(botReport["reference_market"]); $("#sProf").html(all_profitability); $("#reportTradingModeName").html(botReport["trading_mode"]); $("#reportTradingModeNameLink").attr("href", $("#reportTradingModeNameLink").attr("base_href") + botReport["trading_mode"]); const end_portfolio_reports = []; $.each(botReport["end_portfolio"], function (exchange, portfolio) { let exchange_portfolio = show_exchanges ? `${exchange} ` : ""; $.each(portfolio, function (symbol, holdings) { const digits = holdings["total"] > 10 ? 2 : 10; exchange_portfolio = `${exchange_portfolio} ${symbol}: ${round_digits(holdings["total"], digits)}`; }); end_portfolio_reports.push(exchange_portfolio); }); $("#ePort").html(end_portfolio_reports.join(", ")); const starting_portfolio_reports = []; $.each(botReport["starting_portfolio"], function (exchange, portfolio) { let exchange_portfolio = show_exchanges ? `${exchange} ` : ""; $.each(portfolio, function (symbol, holdings) { exchange_portfolio = `${exchange_portfolio} ${symbol}: ${holdings["total"]}`; }); starting_portfolio_reports.push(exchange_portfolio); }); $("#sPort").html(starting_portfolio_reports.join(", ")); last_chart_identifiers = globalReport["chart_identifiers"] fillTimeFrameSelector(last_chart_identifiers); add_graphs(last_chart_identifiers); add_tables(data["trades"], botReport["reference_market"]); } }).fail(function () { report.hide(); }).always(function () { report.attr("loading", "false"); }); } } const fillTimeFrameSelector = (chart_identifiers) => { const selector = $("#timeFrameSelect"); selector.empty(); if(!chart_identifiers.length){ return; } selector.append(...chart_identifiers[0]["time_frames"].map( (tf, index) => new Option(tf, tf) )); selector.val(chart_identifiers[0]["time_frames"][0]); selector.selectpicker('refresh'); } const registerTimeFrameSelector = () => { $("#timeFrameSelect").on("change", () => { add_graphs(last_chart_identifiers) }) } function add_graphs(chart_identifiers){ const result_graph_id = "result-graph-"; const graph_symbol_price_id = "graph-symbol-price-"; const result_graphs = $("#result-graphs"); result_graphs.empty(); $.each(chart_identifiers, function (_, chart_identifier) { const target_template = $("#"+result_graph_id+config_default_value); const symbol = chart_identifier["symbol"]; const exchange_id = chart_identifier["exchange_id"]; const exchange_name = chart_identifier["exchange_name"]; const time_frame = $("#timeFrameSelect").val() ? $("#timeFrameSelect").val() : chart_identifier["time_frames"]; const graph_card = target_template.html().replace(new RegExp(config_default_value,"g"), exchange_id+symbol); result_graphs.append(graph_card); const formated_symbol = symbol.replace(new RegExp("/","g"), "|"); get_symbol_price_graph(`${graph_symbol_price_id}${exchange_id}${symbol}`, exchange_id, exchange_name, formated_symbol, time_frame, true, true); }) } const add_tables = (trades, refMarket) => { return displayTradesTable("result-trades", trades, refMarket, true); } function updateBacktestingProgress(progress){ updateProgressBar("progess_bar_anim", progress); } function refreshBacktestingStatus(){ backtestingSocket.emit('backtesting_status'); } function init_backtesting_status_websocket(){ backtestingSocket = get_websocket("/backtesting"); backtestingSocket.on('backtesting_status', function(backtesting_status_data) { _handle_backtesting(backtesting_status_data); }); } function _handle_backtesting(backtesting_status_data){ const backtesting_status = backtesting_status_data["status"]; const progress = backtesting_status_data["progress"]; const errors = backtesting_status_data["errors"]; const report = $("#backtestingReport"); const progress_bar = $(`#${backtestingMainProgressBar}`); const stopButton = $("#backtester-stop-button"); if(backtesting_status === "computing" || backtesting_status === "starting"){ lock_interface(true); progress_bar.show(); if(stopButton.length){ stopButton.removeClass(hidden_class); } updateBacktestingProgress(progress); first_refresh_state = backtesting_status; if(report.is(":visible")){ report.hide(); } backtesting_computing_callbacks.forEach((callback) => callback()); // re-schedule progress refresh setTimeout(function () {refreshBacktestingStatus()}, 50); } else{ lock_interface(false); progress_bar.hide(); if(stopButton.length){ stopButton.addClass(hidden_class); } if(backtesting_status === "finished"){ const should_alert = first_refresh_state !== "" && first_refresh_state !== "finished"; if(should_alert){ create_alert("success", "Backtesting finished.", ""); first_refresh_state="finished"; } if(!report.is(":visible") && report.attr("loading") === "false"){ report.attr("loading", "true"); load_report(report, should_alert); } if(previousBacktestingStatus === "computing" || previousBacktestingStatus === "starting") { backtesting_done_callbacks.forEach((callback) => callback(errors)); } } } if(first_refresh_state === ""){ first_refresh_state = backtesting_status; } previousBacktestingStatus = backtesting_status; } let first_refresh_state = ""; let backtestingSocket = undefined; const lock_interface_callbacks = []; const backtesting_done_callbacks = []; const backtesting_computing_callbacks = []; let previousBacktestingStatus = undefined; let backtestingMainProgressBar = "backtesting_progress_bar"; let last_chart_identifiers = []; $(document).ready(function() { registerTimeFrameSelector(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/bot_connection.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function init_status_websocket(){ const onBotReconnected = () => _reconnectedCallbacks.forEach((callback) => callback()); const socket = get_websocket("/notifications"); socket.on('update', (data) => { if(_isBotDisconnected){ _isBotDisconnected = false; onBotReconnected(); } unlock_ui(); manage_alert(data); }); socket.on('disconnect', async () => { if(!_isBotDisconnected && await checkDisconnected()){ _isBotDisconnected = true; lock_ui(); } }); } function manage_alert(data){ try{ const errors_count = data["errors_count"]; const errorBadge = $("#errors-count-badge"); if(errorBadge.length){ if(errors_count > 0){ errorBadge.text(errors_count); }else{ errorBadge.text(""); } } const notifications = data["notifications"]; const maxDisplayedNotifications = 10; // only display latest notifications when too many to display const displayedNotifications = ( notifications.length > maxDisplayedNotifications ? notifications.slice(notifications.length - maxDisplayedNotifications, notifications.length): notifications ); $.each(displayedNotifications, (i, item) => { const toastAlertLevel = item["Level"] === "critical" ? "error": item["Level"] create_alert(toastAlertLevel, item["Title"], item["Message"], "", item["Sound"]); $.each(notificationCallbacks, (_, callback) => { callback(item["Title"], item); }); }) } catch(error) { console.log(error); } } function handle_route_button(){ $(".btn").each((_, jsButton) => { if(!jsButton.hasAttribute('route')){ return; } $(jsButton).click((event) => { const button = $(event.currentTarget); if (button[0].hasAttribute('route')){ const command = button.attr('route'); const origin_val = button.text(); $.ajax({ url: command, beforeSend: function() { button.html(""); }, success: function() { create_alert("info", "OctoBot is stopping", ""); }, complete: function() { button.html(origin_val); } }); } }); }); } function send_and_interpret_bot_update(updated_data, update_url, dom_root_element, success_callback, error_callback, method="POST"){ $.ajax({ url: update_url, type: method, dataType: "json", contentType: 'application/json', data: JSON.stringify(updated_data), success: function(msg, status){ if(typeof success_callback === "undefined") { if(dom_root_element != null){ update_dom(dom_root_element, msg); } } else{ success_callback(updated_data, update_url, dom_root_element, msg, status) } }, error: function(result, status, error){ window.console&&console.error(result, status, error); if(typeof error_callback === "undefined") { let error_text = result.responseText.length > 1000 ? status : result.responseText; create_alert("error", "Error when handling action: "+error_text+".", ""); } else{ error_callback(updated_data, update_url, dom_root_element, result, status, error); } } }) } const async_send_and_interpret_bot_update = async (updated_data, update_url, dom_root_element, method="POST", alertOnFailure=true) => { return new Promise((resolve, reject) => { const success = (updated_data, update_url, dom_root_element, msg, status) => { resolve(msg); } const failure = (updated_data, update_url, dom_root_element, msg, status) => { if(alertOnFailure){ generic_request_failure_callback(updated_data, update_url, dom_root_element, msg, status) } reject(msg); } send_and_interpret_bot_update(updated_data, update_url, dom_root_element, success, failure, method); }); } const notificationCallbacks = []; const _reconnectedCallbacks = []; let _isBotDisconnected = false; function isBotDisconnected(){ return _isBotDisconnected; } async function checkDisconnected(){ try { await async_send_and_interpret_bot_update(null, $("#resources-urls").data("ping-url"), null, "GET", false); return false; } catch (err) { return true; } } function register_notification_callback(callback){ notificationCallbacks.push(callback); } function registerReconnectedCallback(callback){ _reconnectedCallbacks.push(callback); } $(document).ready(function () { handle_route_button(); init_status_websocket(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/candlesticks.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function get_symbol_price_graph(element_id, exchange_id, exchange_name, symbol, time_frame, display_orders, backtesting=false, replace=false, should_retry=false, attempts=0, data=undefined, success_callback=undefined, no_data_callback=undefined){ if(isDefined(data)){ create_or_update_candlestick_graph(element_id, data, symbol, exchange_name, time_frame, replace); }else{ const backtesting_enabled = backtesting ? "backtesting" : "live"; const ajax_url = "/dashboard/currency_price_graph_update/"+ exchange_id +"/" + symbol + "/" + time_frame + "/" + backtesting_enabled + "?display_orders=" + display_orders; $.ajax({ url: ajax_url, type: "GET", dataType: "json", contentType: 'application/json', success: function(data, status){ if(data !== null && "error" in data && data["error"].includes("no data for")){ if(isDefined(no_data_callback)) { no_data_callback(element_id); } }else if (!create_or_update_candlestick_graph(element_id, data, symbol, exchange_name, time_frame, replace)){ if (should_retry && attempts < max_attempts){ const marketsElement = $("#loadingMarketsDiv"); marketsElement.removeClass(disabled_item_class); setTimeout(function(){ marketsElement.addClass(disabled_item_class); get_symbol_price_graph(element_id, exchange_id, exchange_name, symbol, time_frame, display_orders, backtesting, replace, should_retry,attempts+1, data, success_callback); }, 3000); } }else{ const loadingSelector = $("div[name='loadingSpinner']"); if (loadingSelector.length) { $.each(loadingSelector, function () { $(this).addClass(disabled_item_class); }); } if(isDefined(success_callback)){ success_callback(); } } }, error: function(result, status, error){ window.console&&console.error(error, result, status); const loadingSelector = $("div[name='loadingSpinner']"); if (loadingSelector.length) { loadingSelector.addClass(hidden_class); } $(document.getElementById(element_id)).html(`Error when loading graph: ${error} [${result.responseText}]. More details in logs.`) } }); } } function get_first_symbol_price_graph(element_id, in_backtesting_mode=false, callback=undefined, time_frame=undefined, display_orders=true) { const url = $("#first_symbol_graph").attr(update_url_attr); $.get(url,function(data) { if($.isEmptyObject(data)){ // no exchange data available yet, retry soon, bot must be starting setTimeout(function(){ get_first_symbol_price_graph(element_id, in_backtesting_mode, callback, time_frame, display_orders); }, 300); }else{ if("time_frame" in data){ let formatted_symbol = data["symbol"].replace(new RegExp("/","g"), "|"); const fetched_time_frame = time_frame ? time_frame : data["time_frame"]; get_symbol_price_graph(element_id, data["exchange_id"], data["exchange_name"], formatted_symbol, fetched_time_frame, display_orders, in_backtesting_mode, false, true, 0, undefined, function () { if(isDefined(callback)){ callback(data["exchange_id"], data["symbol"], data["time_frame"], element_id); } }); } } }); } function get_watched_symbol_price_graph(element, callback=undefined, no_data_callback=undefined, time_frame=undefined, display_orders=true) { const symbol = element.attr("symbol"); let formatted_symbol = symbol.replace(new RegExp("/","g"), "|"); const ajax_url = "/dashboard/watched_symbol/"+ formatted_symbol; $.get(ajax_url,function(data) { if("time_frame" in data){ const fetched_time_frame = time_frame ? time_frame : data["time_frame"]; let formatted_symbol = data["symbol"].replace(new RegExp("/","g"), "|"); get_symbol_price_graph(element.attr("id"), data["exchange_id"], data["exchange_name"], formatted_symbol, fetched_time_frame, display_orders, false, false, true, 0, undefined, function () { if(isDefined(callback)){ callback(data["exchange_id"], data["symbol"], data["time_frame"], element.attr("id")); } }, no_data_callback); }else if($.isEmptyObject(data)){ // OctoBot is starting, try again const marketsElement = $("#loadingMarketsDiv"); marketsElement.removeClass(disabled_item_class); setTimeout(function(){ get_watched_symbol_price_graph(element, callback, no_data_callback, time_frame, display_orders); }, 1000); } }); } const stop_color = getComputedStyle(document.body).getPropertyValue('--local-price-chart-stop-color'); const sell_color = getComputedStyle(document.body).getPropertyValue('--local-price-chart-sell-color'); const buy_color = getComputedStyle(document.body).getPropertyValue('--local-price-chart-buy-color'); const candle_sell_color = getComputedStyle(document.body).getPropertyValue('----local-price-chart-candle-sell-color'); const candle_buy_color = getComputedStyle(document.body).getPropertyValue('--local-price-chart-candle-buy-color'); function create_candlesticks(candles){ const data_time = candles["time"]; const data_close = candles["close"]; const data_high = candles["high"]; const data_low = candles["low"]; const data_open = candles["open"]; return { x: data_time, close: data_close, decreasing: {line: {color: candle_sell_color}}, high: data_high, increasing: {line: {color: candle_buy_color}}, line: {color: 'rgba(31,119,180,1)'}, low: data_low, open: data_open, type: 'candlestick', name: 'Prices', xaxis: 'x', yaxis: 'y2' }; } function create_volume(candles){ const data_time = candles["time"]; const data_close = candles["close"]; const data_volume = candles["vol"]; const colors = []; $.each(data_close, function (i, value) { if(i !== 0) { if (value > data_close[i - 1]) { colors.push(buy_color); }else{ colors.push(sell_color); } } else{ colors.push(sell_color); } }); return { x: data_time, y: data_volume, marker: { color: colors }, type: 'bar', name: 'Volume', xaxis: 'x', yaxis: 'y1' }; } function create_trades(trades, trader){ if (isDefined(trades) && isDefined(trades["time"]) && trades["time"].length > 0) { const data_time = trades["time"]; const data_price = trades["price"]; const data_trade_description = trades["trade_description"]; const data_order_side = trades["order_side"]; const marker_size = 16; const marker_opacity = 0.9; const border_line_color = getTextColor(); const colors = []; $.each(data_order_side, function (index, value) { colors.push(_getOrderColor(trades["trade_description"][index], value)); }); const line_with = isDarkTheme() ? 1 : 0.2; return { x: data_time, y: data_price, mode: 'markers', name: "", text: data_trade_description, hovertemplate: `%{text}
%{x}`, marker: { color: colors, size: marker_size, opacity: marker_opacity, line: { width: line_with, color: border_line_color } }, xaxis: 'x', yaxis: 'y2' } }else{ return {} } } const _getOrderColor = (orderDesc, side) => { if(orderDesc.includes("STOP")){ return stop_color; } return side === "sell" ? sell_color : buy_color } function create_orders(orders, trader, firstTime, lastTime){ const firstDate = new Date(`20${firstTime}`) if (isDefined(orders) && isDefined(orders.time) && orders.time.length > 0) { return orders.time.map((x, index) => { return { x: [new Date(`20${x}`) >= firstDate ? x : firstTime, lastTime], y: [orders.price[index], orders.price[index]], mode: 'lines+markers', text: orders.description[index], hoverinfo: "text", line: { dash: 'dashdot', width: 2, color: _getOrderColor(orders.description[index], orders.order_side[index]), }, marker: { symbol: "star-diamond", }, xaxis: 'x', yaxis: 'y2' } }); }else{ return [] } } function update_trades(trades, trader_name, reference_trades){ if(isDefined(reference_trades) && isDefined(reference_trades.y)){ if(isDefined(trades.time) && trades.time.length){ const new_trades = create_trades(trades, trader_name); if(new_trades.mode){ for(let i=0; i= new_candles["open"][last_candle_index] ? buy_color : sell_color; to_update_vols.marker.color[last_price_trace_index] = prev_vol_color; } function create_layout(graph_title){ return { title: graph_title, dragmode: isMobileDisplay() ? false : 'zoom', margin: { r: 10, t: 25, b: 40, l: 60 }, showlegend: false, xaxis: { autorange: true, domain: [0, 1], title: 'Date', type: 'date', rangeslider: { visible: false, } }, yaxis1: { domain: [0, 0.2], title: 'Volume', autorange: true, showgrid: false, showticklabels: false }, yaxis2: { domain: [0.2, 1], autorange: true, title: 'Price', gridcolor: `rgba(${getTextColorRGB()}, 0.2)`, }, paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)', font: { color: getTextColor(), } }; } function push_new_candle(price_trace, volume_trace, candles, candle_index, last_candle_time){ price_trace.x.push(last_candle_time); price_trace.open.push(candles["open"][candle_index]); price_trace.high.push(candles["high"][candle_index]); price_trace.low.push(candles["low"][candle_index]); price_trace.close.push(candles["close"][candle_index]); volume_trace.y.push(candles["vol"][candle_index]); const vol_color = candles["close"][candle_index] >= candles["open"][candle_index] ? buy_color : sell_color; volume_trace.marker.color.push(vol_color); } function create_or_update_candlestick_graph(element_id, symbol_price_data, symbol, exchange_name, time_frame, replace=false){ if (symbol_price_data) { const candles = symbol_price_data["candles"]; const trades = symbol_price_data["trades"]; const orders = symbol_price_data["orders"]; const isSimulated = symbol_price_data["simulated"] let layout = undefined; let price_trace = undefined; let volume_trace = undefined; let real_trader_trades = undefined; let simulator_trades = undefined; let plotted_orders = undefined; const prev_data = document.getElementById(element_id); const prev_layout = prev_data.layout; if (prev_layout && !replace) { volume_trace = prev_data.data[0]; price_trace = prev_data.data[1]; real_trader_trades = prev_data.data[2]; simulator_trades = prev_data.data[3]; // keep layout layout = prev_layout; // update data revision to force graph update layout.datarevision = layout.datarevision + 1; // trades real_trader_trades = isSimulated ? real_trader_trades : update_trades(trades, "Real trader", real_trader_trades); simulator_trades = isSimulated ? update_trades(trades, "Simulator", simulator_trades) : simulator_trades; // candles if(isDefined(candles) && isDefined(candles.time) && candles.time.length){ const last_price_trace_index = price_trace.close.length - 1; const last_candle_index = candles["close"].length - 1; const last_candle_time = candles["time"][last_candle_index]; if (last_candle_index > 0){ // Candle update with last candle being and in-construction candle if (price_trace.x[last_price_trace_index] !== last_candle_time) { update_last_candle(price_trace, volume_trace, candles, last_price_trace_index, last_candle_index - 1); push_new_candle(price_trace, volume_trace, candles, last_candle_index, last_candle_time); } else { update_last_candle(price_trace, volume_trace, candles, last_price_trace_index, last_candle_index); } } else if(price_trace.x[last_price_trace_index].indexOf(last_candle_time) === -1) { // Candle update with only one candle but this candle is not displayed (no in-construction candle) push_new_candle(price_trace, volume_trace, candles, last_candle_index, last_candle_time); } } } if(!isDefined(layout)){ let graph_title = symbol; if (exchange_name !== "ExchangeSimulator") { graph_title = graph_title + " (" + exchange_name + ", time frame: " + time_frame + ")"; } layout = create_layout(graph_title); } if(!isDefined(price_trace)){ price_trace = create_candlesticks(candles); } if(!isDefined(volume_trace)){ volume_trace = create_volume(candles); } if(!isDefined(real_trader_trades)){ real_trader_trades = isSimulated ? [] : create_trades(trades, "Real trader"); } if(!isDefined(simulator_trades)){ simulator_trades = isSimulated ? create_trades(trades, "Simulator") : []; } const lastTime = price_trace.x[price_trace.x.length - 1]; const firstTime = price_trace.x[0]; plotted_orders = create_orders(orders, isSimulated ? "Simulator": "Real trader", firstTime, lastTime); const data = [volume_trace, price_trace, real_trader_trades, simulator_trades, ...plotted_orders]; const plotlyConfig = { staticPlot: isMobileDisplay(), scrollZoom: false, modeBarButtonsToRemove: ["select2d", "lasso2d", "toggleSpikelines"], responsive: true, showEditInChartStudio: true, displaylogo: false // no logo to avoid 'rel="noopener noreferrer"' security issue (see https://webhint.io/docs/user-guide/hints/hint-disown-opener/) }; if(replace){ Plotly.newPlot(element_id, data, layout, plotlyConfig); }else{ Plotly.react(element_id, data, layout, plotlyConfig); } return true; }else{ return false } } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/common_handlers.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const backButton = $("#back-button"); if(backButton){ backButton.click(function (){ historyGoBack(); }); } const reloadButton = $("#reload-button"); if(reloadButton){ reloadButton.click(function (){ location.reload(); }); } }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/cst.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ // dom data attributes and classes const config_key_attr = "config-key"; const config_value_attr = "config-value"; const current_value_attr = "current-value"; const startup_value_attr = "startup-config-value"; const update_url_attr = "update-url"; const config_type_attr = "config-type"; const config_data_type_attr = "data-type"; const config_root_class = "config-root"; const config_container_class = "config-container"; const config_element_class = "config-element"; const no_activation_click_attr = "no-activation-click"; // dom display classes const success_badge = "badge-success"; const warning_badge = "badge-warning"; const secondary_badge = "badge-secondary"; const primary_badge = "badge-primary"; const modified_badge = "badge-modified"; const modified_class = "warning-color"; const selected_item_class = "selected-item"; const disabled_item_class = "d-none"; const hidden_class = "d-none"; const disabled_class = "disabled-item"; const card_class_modified = "card-modified"; const deck_container_modified_class = "deck-container-modified"; const deck_container_class = "deck-container"; const config_card_class = "config-card"; const added_class = "new_element"; const light_list_item = "list-group-item-light"; const success_list_item = "list-group-item-success"; const activation_pending = "Activation pending restart"; const deactivation_pending = "Deactivation pending restart"; const unsaved_setting = "Unsaved setting"; const activated = "Activated"; const deactivated = "Deactivated"; const config_default_value = "Bitcoin"; const config_default_symbol = "btc"; const evaluator_config_type = "evaluator_config"; const evaluator_list_config_type = "evaluator_list_config"; const trading_config_type = "trading_config"; const tentacles_config_type = "tentacle_config"; const max_attempts = 20; const price_graph_update_interval = 3000; const profitability_update_interval = 5000; const portfolio_update_interval = 60000; const mobile_width_breakpoint = 1024; const medium_width_breakpoint = 1400; const material_colors = ["#2962ff", "#00b8d4", "#00c853", "#aeea00", "#ffab00", "#dd2c00", "#d50000", "#6200ea"]; const material_dark_colors = ["#0039cb", "#0088a3", "#009624", "#79b700", "#c67c00", "#a30000", "#9b0000", "#0a00b6"]; const TimeFramesMinutes = { "1m": 1, "3m": 3, "5m": 5, "15m": 15, "30m": 30, "1h": 60, "2h": 120, "3h": 180, "4h": 240, "6h": 360, "8h": 480, "12h": 720, "1d": 1440, "3d": 4320, "1w": 10080, "1M": 43200, "1y": 524160, } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/custom_elements.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function create_circular_progress_doughnut(element, label1="% Done", label2="% Remaining"){ return new Chart(element.getContext('2d'), { type: 'doughnut', data: { labels: [label1, label2], datasets: [ { data: [0, 100], backgroundColor: ["#F7464A","#949FB1"], hoverBackgroundColor: ["#FF5A5E", "#A8B3C5"] } ] }, options: { responsive: true, cutoutPercentage: 80 } }); } function create_doughnut_chart(element, data, title, displayLegend=true, graphHeight=400, update){ const labels = []; const values = []; const backgroundColors = []; let index = 0; let totalValue = 0; $.each(data, function (_, value) { totalValue += value; }); $.each(data, function (key, value) { if(value > 0){ values.push(value); labels.push(`${key} ${(value/totalValue*100).toFixed(2)}%`); const color = get_color(index); backgroundColors.push(color); index += 1; } }); const plottedData = [{ values: values, labels: labels, marker: { colors: backgroundColors }, textinfo: 'none', type: "pie" }] const layout = { height: graphHeight, legend: { orientation: isMobileDisplay() ? "h": "v", font: { color: getTextColor() } }, showlegend: displayLegend, margin: { t: 0, b: 0, }, paper_bgcolor: 'rgba(0,0,0,0)', } const plotlyConfig = { scrollZoom: false, responsive: true, displayModeBar: false }; if (update){ // Plotly.restyle(element, plottedData); // todo use restyle for better perf Plotly.newPlot(element, plottedData, layout, plotlyConfig); } else { Plotly.newPlot(element, plottedData, layout, plotlyConfig); } } function create_line_chart(element, data, title, fontColor='white', update=true, height=undefined){ const trace = { x: data.map((e) => new Date(e.time*1000)), y: data.map((e) => e.value), fill: "tonexty", type: 'scatter', line: {shape: 'spline'}, }; const minY = Math.min.apply(null, trace.y); const maxDisplayY = Math.max.apply(null, trace.y); const minDisplayY = Math.max(0, minY - ((maxDisplayY - minY) / 2)); const titleSpecs = { text: title, font: { size: 24 }, }; const layout = { title: titleSpecs, height: height, dragmode: isMobileDisplay() ? false : 'zoom', margin: { l: 30, r: 0, t: 40, b: 40, }, xaxis: { autorange: true, showgrid: false, domain: [0, 1], type: 'date', rangeslider: { visible: false, }, automargin: true, }, yaxis1: { showgrid: false, range: [minDisplayY, maxDisplayY], automargin: true, }, paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)', font: { color: fontColor }, }; const plotlyConfig = { staticPlot: isMobileDisplay(), scrollZoom: false, modeBarButtonsToRemove: ["select2d", "lasso2d", "toggleSpikelines"], responsive: true, showEditInChartStudio: false, displaylogo: false // no logo to avoid 'rel="noopener noreferrer"' security issue (see https://webhint.io/docs/user-guide/hints/hint-disown-opener/) }; if(update){ const layoutUpdate = { title: titleSpecs } Plotly.update(element, {x: [trace.x], y: [trace.y]}, layoutUpdate, 0); } else { Plotly.newPlot(element, [trace], layout, plotlyConfig); } } function create_histogram_chart(element, data, titleY1, titleY2, nameYAxis, fontColor='gray', update=true){ const trace1 = { x: data.map((e) => new Date(e.time*1000)), y: data.map((e) => e.y1), marker: { color: getTextColor(), }, opacity: 0.9, line: { width: 4, }, type: 'scatter', name: titleY1, }; // rgb(198,40,40) octobot red // rgb(0,142,0) green const trace2 = { x: data.map((e) => new Date(e.time*1000)), y: data.map((e) => e.y2), marker: { color: data.map((e) => e.y2 > 0 ? 'rgba(0,142,0,.8)': 'rgba(198,40,40,.5)'), line: { color: data.map((e) => e.y2 > 0 ? 'rgb(0,142,0)': 'rgb(198,40,40)'), width: 1.5, } }, yaxis: 'y2', type: 'bar', name: titleY2, }; const maxDisplayY = Math.max(0, Math.max.apply(null, trace2.y) * 1.5); const minDisplayY = Math.min(0, Math.min.apply(null, trace2.y) * 1.5); const layout = { dragmode: isMobileDisplay() ? false : 'zoom', xaxis: { autorange: true, showgrid: false, domain: [0, 1], type: 'date', rangeslider: { visible: false, } }, yaxis1: { showgrid: true, overlaying: 'y2', title: nameYAxis, rangemode: "tozero", gridcolor: "grey" }, yaxis2: { rangemode: "tozero", showgrid: false, showticklabels: false, side: 'right', range: [minDisplayY, maxDisplayY], }, paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)', font: { color: fontColor }, margin: { l: 30, r: 30, t: 40, }, showlegend: false, }; const plotlyConfig = { staticPlot: isMobileDisplay(), scrollZoom: false, modeBarButtonsToRemove: ["select2d", "lasso2d", "toggleSpikelines"], responsive: true, showEditInChartStudio: false, displaylogo: false // no logo to avoid 'rel="noopener noreferrer"' security issue (see https://webhint.io/docs/user-guide/hints/hint-disown-opener/) }; if(update){ Plotly.restyle(element, {x: [trace1.x], y: [trace1.y], y2: [trace2.y]}, 0); } else { Plotly.newPlot(element, [trace1, trace2], layout, plotlyConfig); } } function update_circular_progress_doughnut(chart, done, remaining){ chart.data.datasets[0].data[0] = done; chart.data.datasets[0].data[1] = remaining; chart.update(); } function create_bars_chart(element, labels, datasets, min_y=0, displayLegend=true, fontColor='white', zeroLineColor='black'){ return new Chart(element.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: datasets }, options: { responsive: true, legend: { display: displayLegend, labels: { fontColor: fontColor, fontSize: 15 } }, scales:{ xAxes:[{ ticks:{ fontColor: fontColor, fontSize: 14 } }], yAxes:[{ ticks:{ fontColor: fontColor, fontSize: 14, suggestedMin: min_y }, gridLines:{ zeroLineColor: zeroLineColor } }] } } }); } function update_bars_chart(chart, datasets){ chart.data.datasets[0].data = datasets[0].data; chart.data.datasets[0].backgroundColor = datasets[0].backgroundColor; chart.data.datasets[0].borderColor = datasets[0].borderColor; chart.update(); } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/data_collector_util.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function lock_collector_ui(lock=true){ if(lock){ $(`#${collectorMainProgressBar}`).show(); // reset progress bar $("#total_progess_bar_anim").css('width', 0+'%').attr("aria-valuenow", 0); }else if(collectorHideProgressBarWhenFinished){ $(`#${collectorMainProgressBar}`).hide(); } $('#collect_data').prop('disabled', lock); $('#stop_collect_data').prop('disabled', !lock); } function _refreshDataCollectorStatus(socket){ socket.emit('data_collector_status'); } function updateDataCollectorProgress(current_progress, total_progress){ if(current_progress === 0){ $("#progress_bar_anim-container").hide(); }else{ $("#progress_bar_anim-container").show(); } $("#current_progess_bar_anim").css('width', (current_progress === 0 ? 100 : current_progress)+'%').attr("aria-valuenow", current_progress); $("#total_progess_bar_anim").css('width', total_progress+'%').attr("aria-valuenow", total_progress); $("#progess_bar_anim").css('width', current_progress+'%').attr("aria-valuenow", current_progress); } function init_data_collector_status_websocket(){ const socket = get_websocket("/data_collector"); socket.on('data_collector_status', function(data_collector_status_data) { _handle_data_collector_status(data_collector_status_data, socket); }); } function _handle_data_collector_status(data_collector_status_data, socket){ const data_collector_status = data_collector_status_data["status"]; const current_progress = data_collector_status_data["progress"]["current_step_percent"]; const total_progress = Math.round((data_collector_status_data["progress"]["current_step"] / data_collector_status_data["progress"]["total_steps"]) * 100); const stopButton = $("#collector-stop-button"); if(data_collector_status === "collecting" || data_collector_status === "starting"){ if(stopButton.length){ stopButton.removeClass(hidden_class); } lock_collector_ui(true); updateDataCollectorProgress(current_progress, total_progress); DataCollectorCollectingCallbacks.forEach((callback) => callback()); // re-schedule progress refresh setTimeout(function () {_refreshDataCollectorStatus(socket);}, 100); } else{ if(stopButton.length){ stopButton.addClass(hidden_class); } lock_collector_ui(false); DataCollectorDoneCallbacks.forEach((callback) => callback()); } collectorBacktestingStatus = data_collector_status; } const DataCollectorDoneCallbacks = []; const DataCollectorCollectingCallbacks = []; let collectorBacktestingStatus = undefined; let collectorHideProgressBarWhenFinished = true; let collectorMainProgressBar = "collector_operation"; ================================================ FILE: Services/Interfaces/web_interface/static/js/common/dom_updater.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function update_badge(badge, new_text, new_class){ badge.removeClass(secondary_badge); badge.removeClass(warning_badge); badge.removeClass(success_badge); badge.removeClass(primary_badge); badge.removeClass(modified_badge); badge.addClass(new_class); if (new_class === primary_badge){ badge.addClass(modified_badge); } badge.html(new_text); } function update_list_item(list_item, new_class){ list_item.removeClass(light_list_item); list_item.removeClass(success_list_item); list_item.addClass(new_class); } function update_element_required_marker_and_usability(element, display_marker) { const marker = element.children("[role='required-flag']"); if(display_marker){ marker.removeClass(hidden_class); element.removeClass(disabled_class); element.removeClass(disabled_item_class); }else{ marker.addClass(hidden_class); element.addClass(disabled_class); element.addClass(disabled_item_class); } } function update_element_temporary_look(element){ const set_to_activated = element.attr(current_value_attr).toLowerCase() === "true"; const set_to_temporary = element.attr(current_value_attr).toLowerCase() !== element.attr(config_value_attr).toLowerCase(); const is_back_to_startup_value = element.attr(startup_value_attr).toLowerCase() === element.attr(config_value_attr).toLowerCase(); if(element.hasClass("list-group-item")){ // list item const list_class = (set_to_activated ? success_list_item : light_list_item); update_list_item(element, list_class); } const badge = element.find(".badge"); if(typeof badge !== "undefined") { if(set_to_temporary){ update_badge(badge, unsaved_setting, primary_badge); }else{ if(set_to_activated){ if (!is_back_to_startup_value){ update_badge(badge, activation_pending, warning_badge); }else{ update_badge(badge, activated, success_badge); } }else{ if (!is_back_to_startup_value){ update_badge(badge, deactivation_pending, warning_badge); }else{ update_badge(badge, deactivated, secondary_badge); } } } } } function change_boolean(to_update_element, new_value, new_value_string){ const badge = to_update_element.find(".badge"); const startup_value = to_update_element.attr(startup_value_attr).toLowerCase(); const is_back_to_startup_value = startup_value === new_value_string; if(new_value){ update_list_item(to_update_element, success_list_item); if (!is_back_to_startup_value){ update_badge(badge, activation_pending, warning_badge); }else{ update_badge(badge, activated, success_badge); } }else{ update_list_item(to_update_element, light_list_item); if (!is_back_to_startup_value){ update_badge(badge, deactivation_pending, warning_badge); }else{ update_badge(badge, deactivated, secondary_badge); } } } function update_activated_deactivated_tentacles(root_element, message, element_type){ const config_value_attr = "config-value"; for (const conf_key in message[element_type]) { const new_value = message[element_type][conf_key]; const new_value_type = "boolean"; const new_value_string = new_value.toString(); const to_update_element = root_element.find("#"+conf_key); const attr = to_update_element.attr(config_value_attr); if (isDefined(attr)) { if (attr.toLowerCase() !== new_value_string){ to_update_element.attr(config_value_attr, new_value_string); if(new_value_type === "boolean"){ const bool_val = new_value.toLowerCase() === "true"; change_boolean(to_update_element, bool_val, new_value_string); } } }else{ // todo find cards to update using returned data to_update_element.removeClass(modified_class); } } } function update_dom(root_element, message){ // update global configuration const super_container = $("#super-container"); confirm_all_modified_classes(super_container); // update evaluators config update_activated_deactivated_tentacles(root_element, message, "evaluator_updated_config"); // update trading config update_activated_deactivated_tentacles(root_element, message, "trading_updated_config"); // update tentacles config update_activated_deactivated_tentacles(root_element, message, "tentacle_updated_config"); } function create_alert(a_level, a_title, a_msg, url="_blank", sound=null){ toastr[a_level](a_msg, a_title); if(sound !== null){ new Audio(getAudioMediaUrl(sound)).play(); } toastr.options = { "closeButton": false, "debug": false, "newestOnTop": false, "progressBar": false, "positionClass": "toast-top-right", "preventDuplicates": false, "onclick": null, "showDuration": 300, "hideDuration": 1000, "timeOut": 5000, "extendedTimeOut": 1000, "showEasing": "swing", "hideEasing": "linear", "showMethod": "fadeIn", "hideMethod": "fadeOut" } } function lock_ui(){ $("#main-nav-bar").find($(".nav-link")).addClass("disabled"); update_status(false); } function unlock_ui(){ $(".nav-link").removeClass("disabled"); update_status(true); } function update_status(status){ const icon_status = $("#navbar-bot-status"); // create alert if required if (status && icon_status.hasClass("fa-times-circle")){ create_alert("success", "Reconnected to Octobot", ""); }else if(!status && icon_status.hasClass("fa-check")){ create_alert("error", "Connection lost with Octobot", "Reconnecting..."); } // update central status if (status){ icon_status.removeClass("fa-times-circle icon-black"); icon_status.addClass("fa-check"); icon_status.attr("title","OctoBot operational"); }else{ icon_status.removeClass("fa-check"); icon_status.addClass("fa-times-circle icon-black"); icon_status.attr("title","OctoBot offline"); } } function register_exit_confirm_function(check_function) { const exit_event = 'beforeunload'; $(window).bind(exit_event, function(){ if(check_function()){ return "Exit without saving ?"; } }); } function remove_exit_confirm_function(){ const exit_event = 'beforeunload'; $(window).off(exit_event); } function confirm_all_modified_classes(container){ container.find("."+deck_container_modified_class).each(function () { toggle_class($(this), deck_container_modified_class, false); }); container.find("."+card_class_modified).each(function () { toggle_class($(this), card_class_modified, false); }); container.find("."+added_class).each(function () { toggle_class($(this), added_class, false); }); } function toggle_class(elem, class_type, toogle=true){ if(toogle && !elem.hasClass(class_type)){ elem.addClass(class_type, 500); }else if(!toogle && elem.hasClass(class_type)){ elem.removeClass(class_type); } } function toogle_deck_container_modified(container, modified=true) { toggle_class(container, deck_container_modified_class, modified); } function toogle_card_modified(card, modified=true) { toggle_class(card, card_class_modified, modified); } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/exchange_accounts.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function register_exchanges_checks(check_existing_accounts){ const update_exchanges_details = (exchangeCard, exchangeData) => { const unloggedSupportingIcon = $(exchangeCard.find("[data-role=supporting-exchange]")); const supportingIcon = $(exchangeCard.find("[data-role=supporting-account]")); const validIcon = $(exchangeCard.find("[data-role=valid-account]")); const warnDetailsWrapper = $(exchangeCard.find("[data-role=account-warning-details-wrapper]")); const warnDetails = $(exchangeCard.find("[data-role=account-warning-details]")); warnDetailsWrapper.addClass(hidden_class); const exchangeType = exchangeData["exchange_type"] const newToolTip = `Login successful using ${exchangeType} account` // both have to be changed validIcon.attr("title", newToolTip) validIcon.attr("data-original-title", newToolTip) if(exchangeData["supporting_exchange"]){ if(exchangeData["auth_success"]){ supportingIcon.removeClass(hidden_class); unloggedSupportingIcon.addClass(hidden_class); }else{ supportingIcon.addClass(hidden_class); unloggedSupportingIcon.removeClass(hidden_class); } } if(exchangeData["auth_success"]){ validIcon.removeClass(hidden_class); }else{ validIcon.addClass(hidden_class); if(exchangeData["configured_account"]) { warnDetailsWrapper.removeClass(hidden_class); warnDetails.text(exchangeData["error_message"]); } } } const check_accounts = (exchangeCards) => { const exchangesReq = {}; const apiKey = "Empty"; const apiSecret = apiKey; const apiPassword = apiKey; exchangeCards.forEach((exchangeCard) => { const exchange = exchangeCard.find(".card-body").attr("name"); if(exchange !== config_default_value && typeof exchange !== "undefined") { exchangesReq[exchange] = { exchange: exchange, apiKey: apiKey, apiSecret: apiSecret, apiPassword: apiPassword, sandboxed: exchangeCard.find(`#exchange_${exchange}_sandboxed`).is(':checked') }; } }) if(!Object.keys(exchangesReq).length){ return; } $.post({ url: $("#exchange-container").attr(update_url_attr), data: JSON.stringify(exchangesReq), contentType: 'application/json', dataType: "json", success: function(data, status){ exchangeCards.forEach((exchangeCard) => { const exchange = exchangeCard.find(".card-body").attr("name"); if(typeof data[exchange] !== "undefined"){ update_exchanges_details(exchangeCard, data[exchange]); } }); }, error: function(result, status, error){ window.console&&console.error(`Impossible to check the exchange accounts compatibility: ${result.responseText}. More details in logs.`); } }) } const check_account = (exchangeCard, source, newValue) => { const exchange = exchangeCard.find(".card-body").attr("name"); if(exchange !== config_default_value && exchangeCard.find("#exchange_api-key").length > 0){ const apiKey = source.attr("id") === "exchange_api-key" ? newValue : exchangeCard.find("#exchange_api-key").editable('getValue', true).trim(); const apiSecret = source.attr("id") === "exchange_api-secret" ? newValue : exchangeCard.find("#exchange_api-secret").editable('getValue', true).trim(); const apiPassword = source.attr("id") === "exchange_api-password" ? newValue : exchangeCard.find("#exchange_api-password").editable('getValue', true).trim(); const sandboxed = exchangeCard.find(`#exchange_${exchange}_sandboxed`).is(':checked'); $.post({ url: $("#exchange-container").attr(update_url_attr), data: JSON.stringify({ exchange: { "exchange": exchange, "apiKey": apiKey, "apiSecret": apiSecret, "apiPassword": apiPassword, "sandboxed": sandboxed, } }), contentType: 'application/json', dataType: "json", success: function(data, status){ update_exchanges_details(exchangeCard, data[exchange]); }, error: function(result, status, error){ window.console&&console.error(`Impossible to check the exchange account compatibility: ${result.responseText}. More details in logs.`); } }) } } const exchange_account_check = (e, params) => { const element = $(e.target); element.data("changed", true); check_account(element.parents("div[data-role=exchange]"), element, typeof params === "undefined" ? null : params.newValue); } const register_edit_events = () => { const cards = []; $("div[data-role=exchange]").each(function (){ const card = $(this); const inputs = card.find("a[data-type=text]"); if(inputs.length){ add_event_if_not_already_added(inputs, 'save', exchange_account_check); } const bools = card.find("input[data-type=bool]"); if(bools.length){ add_event_if_not_already_added(bools, 'change', exchange_account_check); } cards.push(card); }); if(check_existing_accounts){ check_accounts(cards); } } register_edit_events(check_existing_accounts); } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/feedback.js ================================================ function displayFeedbackForm(formId, userId, updateUrl) { Tally.openPopup( formId, { hiddenFields: { userId: userId, }, layout: "modal", hideTitle: true, width: 375, emoji: { text: "👋", animation: "wave" }, autoClose: 0, onSubmit: (payload) => { const filedFormDetails = { form_id: formId, user_id: userId, } send_and_interpret_bot_update(filedFormDetails, updateUrl, null, generic_request_success_callback) }, } ); } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/json_editor_settings.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ // set bootstrap 4 theme for JSONEditor (https://github.com/json-editor/json-editor#css-integration) JSONEditor.defaults.options.iconlib = 'fontawesome5'; // custom octobot theme class OctoBotTheme extends JSONEditor.defaults.themes.bootstrap4 { getButton(text, icon, title) { const el = super.getButton(text, icon, title); el.classList.remove("btn-secondary"); el.classList.add("btn-sm", "btn-primary", "waves-effect", "px-2", "px-md-4"); return el; } getCheckbox() { const el = this.getFormInputField('checkbox'); el.classList.add("custom-control-input"); return el; } getCheckboxLabel(text) { const el = this.getFormInputLabel(text); el.classList.add("custom-control-label"); return el; } getFormControl(label, input, description) { const group = document.createElement("div"); if (label && input.type === "checkbox") { group.classList.add("checkbox", "custom-control", "custom-switch"); group.appendChild(input); group.appendChild(label); } else { group.classList.add("form-group"); if (label) { label.classList.add("form-control-label"); group.appendChild(label); } group.appendChild(input); } if (description) group.appendChild(description); return group; } getIndentedPanel () { const el = document.createElement('div') el.classList.add('card', 'card-body', 'mb-3', "px-1", "px-md-3") if (this.options.object_background) { el.classList.add(this.options.object_background) } if (this.options.object_text) { el.classList.add(this.options.object_text) } /* for better twbs card styling we should be able to return a nested div */ return el } } // custom delete confirm prompt class ConfirmArray extends JSONEditor.defaults.editors.array { askConfirmation() { if (this.jsoneditor.options.prompt_before_delete === true) { if (confirm("Remove this element ?") === false) { return false; } } return true; } } JSONEditor.defaults.themes.octobot = OctoBotTheme; JSONEditor.defaults.editors.array = ConfirmArray; JSONEditor.defaults.options.theme = 'octobot'; JSONEditor.defaults.options.required_by_default = true; ================================================ FILE: Services/Interfaces/web_interface/static/js/common/on_load.js ================================================ // Functions required in each page const onPageLoad = () => { // should be run before document is ready const updateStartupMessages = () => { if(!isMobileDisplay()){ $("#startup-messages-collapse-control").attr("aria-expanded", "true"); $("#startup-messages-collapse").addClass("show"); } } updateStartupMessages(); } onPageLoad(); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/pnl_history.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ const loadPnlFullChartHistory = (data, update) => { const unit = $("#pnl_historyChart").data("unit"); const parentDiv = $(`#pnl_historyChart`); if(data.length > 1){ parentDiv.removeClass(hidden_class); let total_pnl = 0; const chartedData = data.map((element) => { total_pnl += element.pnl; return { time: element.ex_t, y1: total_pnl, y2: element.pnl, } }) create_histogram_chart( document.getElementById("pnl_historyChart"), chartedData, `cumulated profit/loss`, "profit/loss", unit, getTextColor(), false ); }else{ parentDiv.addClass(hidden_class); } } const loadPnlTableHistory = (data, update) => { let total_pnl = 0; const hasDetails = data.length && data[0].d !== null; const rows = data.map((element) => { total_pnl += element.pnl; if(hasDetails){ return [ { timestamp: element.d.en_t, date: element.d.en_d, side: element.d.en_s, base: element.d.b, quote: element.q, symbol: element.d.s, exchange: element.d.ex, trades: element.tc, amount: round_digits(element.d.en_a, 8), price: round_digits(element.d.en_p, 8), total : round_digits(element.d.en_a * element.d.en_p, 8), }, { timestamp: element.ex_t, date: element.ex_d, side: element.d.ex_s, base: element.d.b, quote: element.q, symbol: element.d.s, exchange: element.d.ex, trades: element.tc, amount: round_digits(element.d.ex_a, 8), price: round_digits(element.d.ex_p, 8), total : round_digits(element.d.ex_a * element.d.ex_p, 8) }, round_digits(element.pnl, 8), { special: element.d.s_f.map(e => {return {f: round_digits(e.f, 8), c: e.c}}), amount: round_digits(element.d.f, 8), quote: element.q, }, ] }else{ return [ {timestamp: element.ex_t, date: element.ex_d, quote: element.q}, round_digits(element.pnl, 8), round_digits(total_pnl, 8), round_digits(element.pnl_a, 8), ] } }); const pnlTable = $("#pnl_historyTable"); const unit = rows.length ? rows[0][0].quote : pnlTable.data("unit"); let previousOrder = [[0, "desc"]]; if(update){ const previousDataTable = pnlTable.DataTable(); previousOrder = previousDataTable.order(); previousDataTable.destroy(); } const getSideBadge = (side) => { return `${side}` } const getBoldRender = (amount) => { return `${amount}` } const getPnlOrdersDetails = (data) => { return `${getSideBadge(data.side)} ${data.date}: ${getBoldRender(data.amount)} ${data.base} at ${getBoldRender(data.price)}, total ${getBoldRender(data.total)}`; } const columns = ( hasDetails ? [ { title: `Entry (${unit})`, render: (data, type) => { if (type === 'display' || type === 'filter') { return getPnlOrdersDetails(data); } return data.timestamp; }, width: "39%", }, { title: `Close (${unit})`, render: (data, type) => { if (type === 'display' || type === 'filter') { return getPnlOrdersDetails(data); } return data.timestamp; }, width: "39%", }, { title: `${unit} PNL`, width: "11%", }, { title: 'Total fees', render: (data, type) => { if (type === 'display' || type === 'filter') { const base = data.amount ? `${data.amount} ${data.quote}${data.special.length ?' + ' : ''}` : ""; const special = data.special.length ? data.special.map(e => `${e.f} ${e.c}`).join(", ") : ""; return `${base}${special}` } return data.amount; }, width: "11%", }, ] : [ { title: 'Closing time', render: (data, type) => { if (type === 'display' || type === 'filter') { return data.date } return data.timestamp; }, width: "25%", }, { title: `${unit} Profit and Loss`, width: "25%", }, { title: `Cumulated ${unit} Profit and Loss`, width: "25%", }, { title: `${unit} traded volume`, width: "25%", }, ] ); pnlTable.DataTable({ data: rows.reverse(), columns: columns, order: previousOrder, }); } const fetchPnlHistory = async (scale, pair) => { const url = $("#pnl_historyChart").data("url"); if(typeof url === "undefined"){ return []; } return await async_send_and_interpret_bot_update(null, `${url}${scale}&symbol=${pair}`, null, "GET", true) } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/portfolio_history.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const createHistoricalPortfolioChart = (element_id, reference_market, update) => { const element = $(`#${element_id}`); const selectedTimeFrame = "1d"; // todo add timeframe selector const url = `${element.data("url")}${selectedTimeFrame}`; const success = (updated_data, update_url, dom_root_element, msg, status) => { const graphDiv = $(`#profitability_graph`); const defaultDiv = $(`#no_profitability_graph`); const height = isMobileDisplay()? 250 : isMediumDisplay() ? 450 : undefined; if(msg.length > 1){ graphDiv.removeClass(hidden_class); defaultDiv.addClass(hidden_class); const current_value = msg[msg.length - 1].value; const title = `${current_value > 0 ? current_value : '-'} ${reference_market}` create_line_chart(document.getElementById(element_id), msg, title, 'white', update, height); }else{ graphDiv.addClass(hidden_class); defaultDiv.removeClass(hidden_class); } } send_and_interpret_bot_update(null, url, null, success, generic_request_failure_callback, "GET"); } const displayPortfolioHistory = (elementId, referenceMarket, update) => { createHistoricalPortfolioChart(elementId, referenceMarket, update); } const update_display = (update) => { const elementId = "portfolio_historyChart"; const referenceMarket = $(`#${elementId}`).data("reference-market"); displayPortfolioHistory(elementId, referenceMarket, update); } const start_periodic_refresh = () => { setInterval(function() { update_display(true, true); }, profitability_update_interval); } let firstLoad = true; update_display(false); if(firstLoad){ start_periodic_refresh(); } }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/required.js ================================================ // Functions required in each page $(document).ready(function() { const initTooltips = () => { $('[data-toggle="tooltip"]').tooltip(); } const registerThemeSwitch = async () => { $("#theme-switch").click(async () => { const url = $("#theme-switch").data("update-url") const otherColorMode = $("html").data("mdb-theme") === "light" ? "dark" : "light" const data = { "color_mode": otherColorMode } await async_send_and_interpret_bot_update(data, url) location.reload(); }) } initTooltips(); registerThemeSwitch(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/resources_rendering.js ================================================ /* * Drakkar-Software OctoBot-Tentacles * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ const mardownConverter = new showdown.Converter(); const currentURL = `${window.location.protocol}//${window.location.host}`; function markdown_to_html(text) { return mardownConverter.makeHtml( text?.trim().replaceAll("

", "\n\n") ) } function fetch_images() { $(".product-logo").each(function () { const element = $(this); if(element.attr("src") === ""){ $.get(`${currentURL}/${element.attr("url")}`, function(data) { element.attr("src", data["image"]); element.removeClass(hidden_class) const parentLink = element.parent("a"); if (parentLink.attr("href") === ""){ parentLink.attr("href", data["url"]); } }); } }); } function handleDefaultImage(element, url){ const imgSrc = element.attr("src"); element.on("error",function () { if (imgSrc !== url){ element.attr("src", url); } }); if (((element[0].complete && element[0].naturalHeight === 0) && imgSrc !== url) || imgSrc.endsWith(currencyLoadingImageName)){ element.attr("src", url); } } let currencyIdByName = undefined; let currencyIdBySymbol = undefined; let currencyDetails = [] let currencyLogoById = {}; let fetchedCurrencyIds = false; const currencyLoadingImageName = "loading_currency.svg"; const currencyDefaultImage = `${currentURL}/static/img/svg/default_currency.svg`; const currencyListURL = `${currentURL}/api/currency_list`; const currencyLogoURL = `${currentURL}/currency_logos`; function fetchCurrencyIds(){ currencyIdByName = {}; currencyIdBySymbol = {}; $.get({ url: currencyListURL, dataType: "json", success: function (data) { data.forEach((element) => { const name = element["n"].toLowerCase(); if(!currencyIdByName.hasOwnProperty(name)){ // in case of conflicts, keep the first one as top 250 is first in list currencyIdByName[name] = element["i"]; } const symbol = element["s"].toLowerCase(); if(!currencyIdBySymbol.hasOwnProperty(symbol)){ // in case of conflicts, keep the first one as top 250 is first in list currencyIdBySymbol[symbol] = element["i"]; } }); currencyDetails = data; fetchedCurrencyIds = true; // refresh images handleDefaultImages(); }, error: function (result, status) { window.console && console.error(`Impossible to get currency list from coingecko.com: ${result.responseText} (${status})`); } }); } function handleDefaultImages(){ const applyImage = (element, logoUrl) => { if(!element.hasClass("default")){ element.attr("src", logoUrl); } } const useLogo = (element, currencyId) => { let logoUrl = currencyLogoById[currencyId] if (logoUrl === null){ logoUrl = currencyDefaultImage; } applyImage(element, logoUrl); } const fetchLogos = (currencyIds) => { const successcb = (updated_data, update_url, dom_root_element, msg, status) => { msg.forEach((dataElement) => { currencyLogoById[dataElement.id] = dataElement.logo; }) displayImages(false); } const errorcb = (result, status, error) => { window.console && console.error(`Impossible to get currency logos: ${result.responseText} (${status})`); } send_and_interpret_bot_update({currency_ids: [... currencyIds]}, currencyLogoURL, null, successcb, errorcb); } const displayImages = (shouldFetch) => { try { const currencyIds = new Set(); $(".currency-image").each((_, jselement) => { const element = $(jselement); const imgSrc = element.attr("src"); if (imgSrc === "" || imgSrc.endsWith(currencyLoadingImageName)) { if (jselement.hasAttribute("data-currency-id")) { const currencyId = element.attr("data-currency-id").toLowerCase(); if(currencyLogoById.hasOwnProperty(currencyId)){ useLogo(element, currencyId); }else{ currencyIds.add(currencyId); } } else if (jselement.hasAttribute("data-name")) { const name = element.attr("data-name").toLowerCase(); if (typeof currencyIdByName === "undefined") { fetchCurrencyIds(); } else if (fetchedCurrencyIds) { if (currencyIdByName.hasOwnProperty(name)) { const currencyId = currencyIdByName[name]; if(currencyLogoById.hasOwnProperty(currencyId)){ useLogo(element, currencyId); }else{ currencyIds.add(currencyId); } } else { handleDefaultImage(element, currencyDefaultImage); } } } else if (jselement.hasAttribute("data-symbol")) { const symbol = element.attr("data-symbol").toLowerCase(); if (typeof currencyIdBySymbol === "undefined") { fetchCurrencyIds(); } else if (fetchedCurrencyIds) { if (currencyIdBySymbol.hasOwnProperty(symbol)) { const currencyId = currencyIdBySymbol[symbol]; if(currencyLogoById.hasOwnProperty(currencyId)){ useLogo(element, currencyId); }else{ currencyIds.add(currencyId); } } else { handleDefaultImage(element, currencyDefaultImage); } } } } }); if(shouldFetch && currencyIds.size){ fetchLogos(currencyIds); } } catch { // fetching currency ids } } displayImages(true); } function handle_copy_to_clipboard() { $("[data-role=\"copy-to-clipboard\"]").on("click", (event) => { const element = $(event.currentTarget); copyToClipBoard(element.data("name"), element.data("value")); }) } $(document).ready(function() { // register error listeners as soon as possible handleDefaultImages(); handle_copy_to_clipboard(); $(".markdown-content").each(function () { const element = $(this); element.html(markdown_to_html(element.text())); }); fetch_images(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/stepper.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function updateProgress(){ const progress = getCurrentStepId() * 100 / getStepsCount(); $(".progress-bar").css('width', progress+'%').attr("aria-valuenow", progress); } function triggerCallbacksIfAny(stepId){ if (isDefined(stepperCallbackById && isDefined(stepperCallbackById[stepId]))){ stepperCallbackById[stepId](); } } function updateButtonsDisplay(){ const currentStepId = getCurrentStepId(); const stepsCount = getStepsCount(); const previousButton = $("#previous-step"); const nextButton = $("#next-step"); if(currentStepId < 2){ previousButton.addClass("disabled"); }else{ previousButton.removeClass("disabled"); } if(currentStepId >= stepsCount){ nextButton.addClass("disabled"); }else{ nextButton.removeClass("disabled"); } } function getStep(stepId){ return $(`.tutorial-step[data-step-id=${stepId}]`); } function getCurrentStep(){ return $(".tutorial-step").not(".d-none"); } function getCurrentStepId(){ return getCurrentStep().data("stepId"); } function getStepsCount(){ return $(".tutorial-step").length; } function changeStep(next){ const currentStep = getCurrentStepId(); const nextStepId = next ? currentStep + 1 : currentStep - 1; if(nextStepId > 0 && nextStepId <= getStepsCount()){ getCurrentStep().addClass(hidden_class); getStep(nextStepId).removeClass(hidden_class); window.scrollTo(0, 0); } updateButtonsDisplay(); updateProgress(); triggerCallbacksIfAny(getCurrentStepId()); } function handleStepsButtons(){ const previousButton = $("#previous-step"); const nextButton = $("#next-step"); nextButton.click(function (){ changeStep(true); }); previousButton.click(function (){ changeStep(false); }); } $(document).ready(function() { updateButtonsDisplay(); updateProgress(); handleStepsButtons(); triggerCallbacksIfAny(getCurrentStepId()); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/tables_display.js ================================================ const MAX_PRICE_DIGITS = 8; const _displaySort = (data, type) => { if (type === 'display') { return data.display } return data.sort; } const displayTradesTable = (elementId, trades, refMarket, update) => { const table = $(document.getElementById(elementId)); const rows = trades.map((element) => { return [ element.symbol, element.type, round_digits(element.price, MAX_PRICE_DIGITS), round_digits(element.amount, MAX_PRICE_DIGITS), element.exchange, {display: `${round_digits(element.cost, 5)} ${element.market}`, sort: element.cost}, { display: `${element.ref_market_cost === null ? `no ${element.market} price in ${refMarket}` : round_digits(element.ref_market_cost, 5)}`, sort: element.ref_market_cost === null ? 0 : element.ref_market_cost }, {display: `${round_digits(element.fee_cost, 5)} ${element.fee_currency}`, sort: element.fee_cost}, {display: element.date, sort: element.time}, element.id, element.SoR, ] }); let previousSearch = undefined; let previousOrder = [[8, "desc"]]; let addedRows = true; if (update && $.fn.DataTable.isDataTable(`#${elementId}`)) { const previousDataTable = table.DataTable(); previousSearch = previousDataTable.search(); previousOrder = previousDataTable.order(); addedRows = rows.length !== previousDataTable.rows().data().length; previousDataTable.destroy(); } table.DataTable({ data: rows, columns: [ {title: "Pair"}, {title: "Type"}, {title: "Price"}, {title: "Quantity"}, {title: "Exchange"}, {title: "Total", render: _displaySort}, {title: `${refMarket} Total`, render: _displaySort}, {title: "Fee", render: _displaySort}, {title: "Execution", render: _displaySort}, {title: "ID"}, {title: "#"}, ], paging: true, search: { search: previousSearch, }, order: previousOrder, }); return addedRows; } const displayPositionsTable = (elementId, positions, closePositionUrl, update) => { const table = $(document.getElementById(elementId)); const rows = positions.map((element) => { return [ `${element.side.toUpperCase()} ${element.contract}`, round_digits(element.amount, 5), {display: `${round_digits(element.value, 5)} ${element.market}`, sort: element.value}, round_digits(element.entry_price, MAX_PRICE_DIGITS), round_digits(element.liquidation_price, MAX_PRICE_DIGITS), {display: `${round_digits(element.margin, 5)} ${element.market}`, sort: element.margin}, {display: `${round_digits(element.unrealized_pnl, 5)} ${element.market}`, sort: element.unrealized_pnl}, element.exchange, element.SoR, {symbol: element.symbol, side: element.side}, ] }); let previousSearch = undefined; let previousOrder = undefined; if (update) { const previousDataTable = table.DataTable(); previousSearch = previousDataTable.search(); previousOrder = previousDataTable.order(); previousDataTable.destroy(); } table.DataTable({ data: rows, columns: [ {title: "Contract"}, {title: "Size"}, {title: "Value", render: _displaySort}, {title: "Entry price"}, {title: "Liquidation price"}, {title: "Position margin", render: _displaySort}, {title: "Unrealized PNL", render: _displaySort}, {title: "Exchange"}, {title: "#"}, { title: "Close", render: (data, type) => { if (type === 'display') { return `` } return data; }, }, ], paging: false, search: { search: previousSearch, }, order: previousOrder, }); } const displayOrdersTable = (elementId, orders, cancelOrderUrl, update) => { const table = $(document.getElementById(elementId)); const canCancelOrders = cancelOrderUrl !== undefined; const rows = orders.map((element) => { const row = [ element.symbol, element.type, round_digits(element.price, MAX_PRICE_DIGITS), round_digits(element.amount, MAX_PRICE_DIGITS), element.exchange, {display: element.date, sort: element.time}, {display: `${round_digits(element.cost, MAX_PRICE_DIGITS)} ${element.market}`, sort: element.cost}, element.SoR, element.id, ] if (canCancelOrders){ row.push(element.id) } return row }); let previousOrder = [[5, "desc"]]; if(update){ const previousDataTable = table.DataTable(); previousOrder = previousDataTable.order(); previousDataTable.destroy(); } const columns = [ { title: "Pair" }, { title: "Type" }, { title: "Price" }, { title: "Quantity" }, { title: "Exchange" }, { title: "Date", render: _displaySort }, { title: "Total", render: _displaySort }, { title: "#" }, ] if (canCancelOrders) { columns.push({ title: "Cancel", render: (data, type) => { if (type === 'display') { return `` } return data; }, }); } table.DataTable({ data: rows, columns: columns, paging: false, order: previousOrder, }); } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/tracking.js ================================================ function posthog_loaded(posthog) { const getUserEmail = () => { return getUserDetails().email || ""; } const getUserDetails = () => { if (_USER_DETAILS.email === ""){ // do not erase email if unset delete _USER_DETAILS.email; } return _USER_DETAILS } const updateUserDetails = () => { posthog.capture( 'up_user_details', properties={ '$set': getUserDetails(), } ) } const shouldUpdateUserDetails = () => { const currentProperties = posthog.get_property('$stored_person_properties'); if(currentProperties === undefined){ return true; } if(isDefined(currentProperties)){ const currentDetails = getUserDetails(); if(currentDetails.email === undefined){ // compare without email (otherwise result is always different as no email is currently set) const localProperties = JSON.parse(JSON.stringify(currentProperties)); delete localProperties.email return JSON.stringify(localProperties) !== JSON.stringify(getUserDetails()); } } return JSON.stringify(currentProperties) !== JSON.stringify(getUserDetails()); } const shouldReset = (newEmail) => { const previousId = posthog.get_distinct_id(); return ( newEmail !== previousId // if @ is the user id, it's an email which is different from the current one: this is a new user && previousId.indexOf("@") !== -1 ); } const identify = (email) => { posthog.identify( email, getUserDetails() // optional: set additional person properties ); } const updateUserIfNecessary = () => { if (!_IS_ALLOWING_TRACKING){ // tracking disabled return } const email = getUserEmail(); if (email !== "" && posthog.get_distinct_id() !== email){ if (shouldReset(email)){ // If you also want to reset the device_id so that the device will be considered a new device in // future events, you can pass true as an argument // => past events will be bound to the current user as soon as he connects but avoid binding later events // in case the user changes console.log("PH: Resetting user") const resetDeviceId = true posthog.reset(resetDeviceId); } // new authenticated email: identify console.log("PH: Identifying user") identify(email); }else{ if (shouldUpdateUserDetails()){ console.log("PH: updating user details") updateUserDetails(); } } } updateUserIfNecessary(); } ================================================ FILE: Services/Interfaces/web_interface/static/js/common/tutorial.js ================================================ function getWebsiteLink(route, name) { return `${name}` } function getDocsLink(route, name) { return `${name}` } function getExchangesDocsLink(route, name) { return `${name}` } _TUTORIALS = { home: () => { let profileName = "selected"; if($(`span[data-selected-profile]`).length){ profileName = $(`span[data-selected-profile]`).data("selected-profile"); } return { steps: [ { title: 'Welcome to OctoBot', intro: `Your OctoBot is now trading using the ${profileName} profile.` }, { title: 'Quickly navigate through your OctoBot', element: document.querySelector('#main-nav-bar'), intro: '' }, { title: 'Your live OctoBot', element: document.querySelector('#main-nav-left-part'), intro: 'See and configure your live OctoBot.' }, { title: 'Trading activity', element: document.querySelector('#main-nav-trading'), intro: `View your OctoBot's current open orders and trades history.` }, { title: 'Portfolio', element: document.querySelector('#main-nav-portfolio'), intro: `Quickly checkout your funds at any given time, on every exchange.` }, { title: 'Profile', element: document.querySelector('#main-nav-profile'), intro: `Change any setting about your profile (traded cryptocurrencies, exchanges, strategies, ...).` }, { title: 'Trading type', element: document.querySelector('#main-nav-trading-type'), intro: 'See if your Octobot is trading simulated or real funds.' }, { title: 'Test your profile', element: document.querySelector('#main-nav-backtesting'), intro: 'Backtest your current configuration using historical data.' }, { title: 'Community', element: document.querySelector('#main-nav-community'), intro: 'Access OctoBot cloud strategies, your OctoBot account and the community stats.' }, { title: 'Customize your dashboard', element: document.querySelector('#all-watched-markets'), intro: 'Add watched markets from the Trading tab.' }, { title: "That's it !", intro: 'We hope you will enjoy OctoBot. Use the buttons to learn more on how to use OctoBot' }, ] } }, profile: () => { return { steps: [ { title: 'Profile configuration', intro: 'From this tab, you can configure your OctoBot profile.' }, { title: 'Select another profile', element: document.querySelector('#profile-selector-link'), intro: 'You can change the profile used by your OctoBot at any time.' }, { title: 'Customize your profiles', element: document.querySelector('#edit-profiles-button'), intro: 'You can create you own profiles based on existing ones.' }, { title: 'Set your profile strategy', element: document.querySelector('#panelStrategies-tab'), intro: "Select and configure your current profile's trading mode and configuration." }, { title: 'Select traded cryptocurrencies', element: document.querySelector('#panelCurrency-tab'), intro: "Select the cryptocurrencies to trade on your current profile." }, { title: 'Select exchanges', element: document.querySelector('#panelExchanges-tab'), intro: "Select the exchange(s) to trade on with your current profile." }, { title: 'Select trading configuration', element: document.querySelector('#panelTrading-tab'), intro: "Select whether to trade using simulated funds or your real funds on exchanges." }, { title: 'Save your changes', element: document.querySelector('#save-config'), intro: "When configuring your profile, changes saved when you hit 'save'." }, { title: 'See also', intro: `More details on ${getDocsLink("/octobot-configuration/profiles?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=profiles_intro", "the profiles guide")}.` }, ] } }, profile_selector: () => { return { steps: [ { title: 'Welcome to OctoBot', intro: `To start with OctoBot, select the trading profile that you want to use at first.` }, { title: 'Choosing your profile', element: document.querySelector('[data-target="#defaultModal"]'), intro: `Find more details on each profile using the details button.` }, { title: 'Select your profile', element: document.querySelector('.activate-profile-button'), intro: `Once you found the right profile, just activate it.` }, { title: 'Get more profiles', element: document.querySelector('.login_box'), intro: `Use OctoBot cloud to add profiles to your OctoBot.` }, ] } }, automations: () => { return { steps: [ { title: 'Welcome to automations', intro: `Here you can automate any action directly form your OctoBot.` }, { title: 'What are automations ?', element: document.querySelector('#configEditor'), intro: `Automations are actions your OctoBot can process on a given event or frequency.` }, { title: 'Example 1/2', element: document.querySelector('#configEditor'), intro: `Make your OctoBot send you a notification if your profitability increased by 10% in a day.` }, { title: 'Example 2/2', element: document.querySelector('#configEditor'), intro: `Cancel all open orders if the price of BTC/USDT crosses 70.000 USDT.` }, { title: 'Launch automations', element: document.querySelector('#applyAutomations'), intro: `Automations are started with your OctoBot and when hitting the Apply button.` }, { title: 'Automations are saved in your profile', element: document.querySelector('#page-title'), intro: `You can quickly switch automations by switching profiles.` }, { title: 'Share automations', element: document.querySelector('#page-title'), intro: `As they are linked to a profile, you can share them with your profile.` }, ] } }, profitability: () => { return { steps: [ { title: 'Your profitability', element: document.querySelector('#profitability-display'), intro: 'Your OctoBot trading profitability compared to the market.' }, { title: 'See also', intro: `More details on ${getDocsLink("/octobot-usage/understanding-profitability?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=dashboard_intro", "the OctoBot docs")}.` }, ] } }, "mm:home": () => { return { steps: [ { title: 'Welcome to OctoBot Market Making', intro: 'This free software lets you easily automate market making strategies.' }, { title: 'This is your dashboard', element: document.querySelector('#dashboard-graph'), intro: `From this graph, you can follow your market price, market making orders and trades.` }, { title: 'Simulated trading', element: document.querySelector('#trading-type-indicator'), intro: `This part shows if your bot is currently using virtual funds (simulated trading) or trades with a real exchange account.` }, { title: 'Your open orders', element: document.querySelector('#openOrderTable'), intro: `In this table are displayed details about your strategy current open orders.` }, { title: 'Your account balance', element: document.querySelector('#profitability-display'), intro: `Here will be displayed the chart of your historical balance, once your bot will have run for some time.` }, { title: 'Your trades', element: document.querySelector('#trades-table'), intro: `Your market making trade history will be detailed on this table.` }, { title: "That's it !", intro: 'Thank your for using OctoBot Market Making.' }, ] } }, "mm:configuration": () => { return { steps: [ { title: 'Configuration', intro: 'This page lets you configure your strategy.' }, { title: 'Exchange and pair', element: document.querySelector('#exchange-and-pair'), intro: `Select the exchange and trading pair to provide liquidity on.` }, { title: 'Exchanges configuration', element: document.querySelector('#exchange-configuration'), intro: `You can enter your target exchange and API Keys here.` }, { title: 'Simulated trading', element: document.querySelector('#trading-simulation'), intro: `Use the risk-free trading simulator to fine tune your configuration before using real funds.` }, { title: 'Strategy details', element: document.querySelector('#trading-mode-config-editor'), intro: `Edit your strategy details to create the strategy of your choice.` }, ] } }, account_exchanges: () => { return { steps: [ { title: 'Adding exchanges', element: document.querySelector('#new-exchange-selector'), intro: 'Add as many exchanges as you like. You can enable or disable them in each profile.' }, { title: 'Exchanges configuration', intro: 'Exchange configurations are only required to trade with real funds on the exchange.' }, { title: 'See also', intro: `More details on supported exchanges in the ${getExchangesDocsLink("?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=exchanges_config", "OctoBot exchanges docs")}.` }, ] } }, backtesting: () => { return { steps: [ { title: 'Backtesting', intro: 'Test your current profile using historical data.' }, { title: 'Get historical', element: document.querySelector('#data-collector-link'), intro: 'Download historical market data to test your profiles on.' }, { title: 'See also', intro: `More details the ${getDocsLink("/octobot-usage/backtesting?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=backtesting_intro", "backtesting guide")}.` }, ] } }, } function registerTutorial(tutorialName, callback){ _TUTORIALS[tutorialName] = callback } function displayLocalTutorial(tutorialName, afterExitCallback){ if(typeof _TUTORIALS[tutorialName] === "undefined"){ console.error(`Tutorial not found ${tutorialName}`) return; } const defaultOptions = { disableInteraction: true, showProgress: true, showBullets: false, } const intro = introJs().setOptions(defaultOptions).setOptions(_TUTORIALS[tutorialName]()); if(afterExitCallback !== null){ intro.onexit(afterExitCallback); } intro.start(); } function startTutorialIfNecessary(tutorialName, afterExitCallback=null) { if($(`span[data-display-intro="True"]`).length === 0){ return false; } displayLocalTutorial(tutorialName, afterExitCallback); return true; } $(document).ready(function () { $(`a[data-intro]`).each((_, element) => { $(element).on("click", (event) => { displayLocalTutorial($(event.currentTarget).data("intro"), null) }) }) }); ================================================ FILE: Services/Interfaces/web_interface/static/js/common/util.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function get_websocket(namespace){ // Connect to the Socket.IO server. // The connection URL has the following format, relative to the current page: // http[s]://:[/] return io( namespace, { reconnectionDelay: 2000, // Prevent unexpected disconnection on slow loading pages (ex: first config load) transports: ["polling", "websocket"], // update polling to ws when possible }); } function getAudioMediaUrl(mediaName){ const baseUrl = $("#resources-urls").data("audio-media-url") return `${baseUrl}${mediaName}` } function setup_editable(){ $.fn.editable.defaults.mode = 'inline'; } function get_color(index){ let color_index = index % (material_colors.length); return material_colors[color_index]; } function get_dark_color(index){ let color_index = index % (material_dark_colors.length); return material_dark_colors[color_index]; } function handle_editable(){ const elements = [] $(".editable").each(function(){ elements.push($(this).editable()); }); return elements } function hide_editables(elements){ elements.forEach((element) => { element.editable('hide'); // element.destroy(); }) } function trigger_file_downloader_on_click(element){ if(element.length){ element.click(function (){ window.window.location = $(this).attr("data-url"); }); } } function replace_break_line(str, replacement=""){ return str.replace(/(?:\r\n|\r|\n)/g, replacement); } function replace_spaces(str, replacement=""){ return str.replace(/ /g, replacement); } function get_selected_options(element){ const selected_options = []; element.find(":selected").each(function(){ selected_options.push($(this).val()); }); return selected_options; } // utility functions function isDefined(thing){ return (typeof thing !== "undefined" && thing !== false && thing !==null); } function log(...texts){ if(window.console){ console.log(...texts); } } function get_events(elem, event_type){ const events = $._data( elem[0], 'events' ); if(typeof events === "undefined"){ return []; } return $._data( elem[0], 'events' )[event_type]; } function add_event_if_not_already_added(elem, event_type, handler){ if(!check_has_event_using_handler(elem, event_type, handler)){ elem.on(event_type, handler); } } function updateProgressBar(elementId, progress){ $(document.getElementById(elementId)).css('width', progress+'%').attr("aria-valuenow", progress); } function check_has_event_using_handler(elem, event_type, handler){ const events = get_events(elem, event_type); let has_events = false; $.each(events, function () { if($(this)[0]["handler"] === handler){ has_events = true; } }); return has_events; } function generic_request_success_callback(updated_data, update_url, dom_root_element, msg, status) { if(msg.hasOwnProperty("title")){ create_alert("success", msg["title"], msg["details"]); }else{ create_alert("success", msg, ""); } } function generic_request_failure_callback(updated_data, update_url, dom_root_element, msg, status) { if(isBotDisconnected()){ create_alert("error", "Can't connect to OctoBot", "Your OctoBot might be offline."); }else{ create_alert("error", msg.responseText, ""); } } function isMobileDisplay() { return $(window).width() < mobile_width_breakpoint; } function isMediumDisplay() { return $(window).width() < medium_width_breakpoint; } const getTextColor = () => { return getComputedStyle(document.body).getPropertyValue('--mdb-primary-text-emphasis') } const getTextColorRGB = () => { return getComputedStyle(document.body).getPropertyValue('--mdb-emphasis-color-rgb') } const isDarkTheme = () => { return $("html").data("mdb-theme") === "dark" } const handle_rounded_numbers_display = () => { $(".rounded-number").each(function (){ const text = $(this).text().trim(); if (!isNaN(text)){ const value = Number(text); const decimal = value > 1 ? 3 : 8; $(this).text(handle_numbers(round_digits(text, decimal))); } }); } function round_digits(number, decimals) { const rounded = Number(Math.round(`${number}e${decimals}`) + `e-${decimals}`); if(isNaN(rounded)){ const n = Number(`${number}`); return n.toFixed(decimals); } return rounded; } function handle_numbers(number) { let regEx2 = /[0]+$/; let regEx3 = /[.]$/; const numb_repr = Number(number); const numb_str = numb_repr.toString(); let numb_digits = numb_str.length; const exp_index = numb_str.indexOf('e-'); if (exp_index > -1){ let decimals = 0; if (numb_str.indexOf('.') > -1) { decimals = numb_str.substr(0, exp_index).split(".")[1].length; } numb_digits = Number(numb_str.split("e-")[1]) + decimals; } let numb = numb_repr.toFixed(numb_digits); if (numb.indexOf('.')>-1){ numb = numb.replace(regEx2,''); // Remove trailing 0's } return numb.replace(regEx3,''); // Remove trailing decimal } function fix_config_values(config, schema){ ensure_all_config_values(config, schema); $.each(config, function (key, val) { if(typeof val === "number"){ config[key] = handle_numbers(val); }else if (val instanceof Object){ fix_config_values(config[key], undefined); } }); } const ensure_all_config_values = (config, schema) => { if(!isDefined(schema) || typeof schema.properties === "undefined"){ return } // ensure each schema element has a value or there might be display issues Object.keys(schema.properties).forEach(key => { if(typeof config[key] === "undefined"){ config[key] = schema.properties[key].default; } }) } function getValueChangedFromRef(newObject, refObject, allowUndefinedValues=true) { let changes = false; if (newObject instanceof Array && newObject.length !== refObject.length){ changes = true; } else{ Object.keys(newObject).forEach((key) => { if(changes){ return; } const val = newObject[key]; const refVal = refObject[key]; if (allowUndefinedValues && (typeof refVal === "undefined" || typeof val === "undefined")){ // ignore missing values return; } else if (val instanceof Array || val instanceof Object){ changes = getValueChangedFromRef(val, refVal, allowUndefinedValues); } else if (refObject[key] !== val){ if (typeof val === "number"){ changes = Number(refVal) !== val; }else{ changes = true; } } if (changes){ return; } }); } return changes; } function historyGoBack() { window.history.back(); } function showModalIfAny(element){ if(element){ element.modal(); } } function hideModalIfAny(element){ if(element){ element.modal("hide"); } } // Inspired from https://davidwalsh.name/javascript-debounce-function // Function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. If `immediate` is passed, trigger the function on the // leading edge, instead of the trailing. const debounce = (func, wait, immediate) => { let debounceTimeout; return () => { const context = this; const later = () => { debounceTimeout = null; if (!immediate) { func.apply(context); } }; const callNow = immediate && !debounceTimeout; clearTimeout(debounceTimeout); debounceTimeout = setTimeout(later, wait); if (callNow) { func.apply(context); } } } function unique(array){ return $.grep(array, function(el, index) { return index === $.inArray(el, array); }); } function download_data(data, filename, content_type="application/json"){ let a = window.document.createElement('a'); a.href = window.URL.createObjectURL(new Blob([data], {type: content_type})); a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); } function display_generic_modal(title, content, warning, yes_button_callback, no_button_callback){ let generic_modal = $("#genericModal"); $("#genericModalTitle").text(title); $("#genericModalContent").text(content); if(warning !== ""){ $("#genericModalWarning").removeClass(hidden_class); $("#genericModalWarningMessage").text(warning); } $("#genericModalButtonYes").on("click", function() { yes_button_callback(); hideModalIfAny(generic_modal); }); $("#genericModalButtonNo").on("click", function(){ if(no_button_callback !== null){ no_button_callback(); } hideModalIfAny(generic_modal); }); showModalIfAny(generic_modal); return generic_modal; } function updateInputIfValue(elementId, config, configKey, elementType){ const value = config[configKey]; if(typeof value !== "undefined" && value !== null && value !== ""){ const element = $(document.getElementById(elementId)); if(element.length){ if(elementType === "date") { element.val(new Date(config[configKey]).toISOString().split('T')[0].slice(0, 10)) } else if(elementType === "bool"){ element.prop("checked", config[configKey]); } else { element.val(config[configKey]); } } } } function randomizeArray(array) { array.sort(() => Math.random() - 0.5); } function validateJSONEditor(editor) { const errors = editor.validate(); let errorsDesc = ""; if(errors.length) { window.console&&console.error("Errors when validating editor:", errors); errors.map((error) => { errorsDesc = `${errorsDesc}${error.path.split("root.")[1]} ${error.message}\n` }) } return errorsDesc; } function getWebsiteUrl() { return $("#global-urls").data("website-url"); } function getDocsUrl() { return $("#global-urls").data("docs-url"); } function getExchangesDocsUrl() { return $("#global-urls").data("exchanges-docs-url"); } function paginatedSelect2(selectElement, options, pageSize){ // WIP: issue: focus not working on options jQuery.fn.select2.amd.require( ["select2/data/array", "select2/utils"], (ArrayData, Utils) => { function CustomData($element, options) { CustomData.__super__.constructor.call(this, $element, options); } Utils.Extend(CustomData, ArrayData); CustomData.prototype.query = function (params, callback) { let results = []; if (typeof params.term !== "undefined" && params.term !== '') { const toSearch = params.term.toUpperCase() results = options.filter((option) => { return option.text.toUpperCase().indexOf(toSearch) !== -1; }); } else { results = options; } if (!("page" in params)) { params.page = 1; } const data = {}; data.results = results.slice((params.page - 1) * pageSize, params.page * pageSize); data.pagination = {}; data.pagination.more = params.page * pageSize < results.length; callback(data); }; // add select2 selector selectElement.select2({ ajax: {}, width: '200', // need to override the changed default tags: true, dataAdapter: CustomData }); } ); } const sortTimeFrames = (timeFrames) => { timeFrames.sort((a, b) => TimeFramesMinutes[a] - TimeFramesMinutes[b]); return timeFrames; } function activate_tab(tabElement, nestedNavBar=undefined){ if(!tabElement.hasClass("active")){ if(typeof nestedNavBar !== "undefined"){ // manually handle sidebar navigation to work with nested elements nestedNavBar.each(function (){ $(this).removeClass("active"); }) } tabElement.tab('show'); } } function selectFirstTab(nestedNavBar=undefined){ let activatedTab = false; const anchor = $(location).attr('hash'); if (anchor){ const tab = $(`${anchor}-tab`); if (typeof tab !== "undefined") { activate_tab(tab, nestedNavBar); activatedTab = true; } } if (!activatedTab){ activate_tab($("[data-tab='default']"), nestedNavBar); } } function copyToClipBoard(name, value) { if(!navigator.clipboard){ create_alert( "error", "Browser security is preventing copy. Please manually copy this value" ); } navigator.clipboard.writeText(value); create_alert("success", `${name} copied to clipboard`); } async function sleep(milliseconds) { return new Promise(r => setTimeout(r, milliseconds)) } ================================================ FILE: Services/Interfaces/web_interface/static/js/components/advanced_matrix.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function init_select_filter(){ let evaluators_array = []; let timeframes_array = []; let symbols_array = []; let exchanges_array = []; $.each(matrix_table.rows().data(), function(index, data) { evaluators_array.push(data[matrix_table_evaluator_index]); timeframes_array.push(data[matrix_table_timeframe_index]); symbols_array.push(data[matrix_table_symbol_index]); exchanges_array.push(data[matrix_table_exchange_index]); }); evaluators_array = unique(evaluators_array); timeframes_array = unique(timeframes_array); symbols_array = unique(symbols_array); exchanges_array = unique(exchanges_array); let evaluators_select = $("#evaluatorsSelect").select2({ closeOnSelect: false, placeholder: "Evaluators" }); $.each(evaluators_array, function(index, value) { evaluators_select[0].add(new Option(value,value)); }); evaluators_select.on('change', function(){ evaluators_selected = evaluators_select.val(); matrix_table.columns(matrix_table_evaluator_index).search( evaluators_selected.length ? ('^(' + evaluators_selected.join("|") + ')$') : '', true, false ).draw(); }); let timeframes_select = $("#timeframesSelect").select2({ closeOnSelect: false, placeholder: "Timeframes" }); $.each(timeframes_array, function(index, value) { timeframes_select[0].add(new Option(value,value)); }); timeframes_select.on('change', function(){ timeframes_selected = timeframes_select.val(); matrix_table.columns(matrix_table_timeframe_index).search( timeframes_selected.length ? ('^(' + timeframes_selected.join("|") + ')$') : '', true, false ).draw(); }); let symbols_select = $("#symbolsSelect").select2({ closeOnSelect: false, placeholder: "Symbols" }); $.each(symbols_array, function(index, value) { symbols_select[0].add(new Option(value,value)); }); symbols_select.on('change', function(){ symbols_selected = symbols_select.val(); matrix_table.columns(matrix_table_symbol_index).search( symbols_selected.length ? ('^(' + symbols_selected.join("|") + ')$') : '', true, false ).draw(); }); let exchanges_select = $("#exchangesSelect").select2({ closeOnSelect: false, placeholder: "Exchanges" }); $.each(exchanges_array, function(index, value) { exchanges_select[0].add(new Option(value,value)); }); exchanges_select.on('change', function(){ exchanges_selected = exchanges_select.val(); matrix_table.columns(matrix_table_exchange_index).search( exchanges_selected.length ? ('^(' + exchanges_selected.join("|") + ')$') : '', true, false ).draw(); }); } const matrix_table = $('#matrixDataTable').DataTable(); const matrix_table_evaluator_index = 0; const matrix_table_timeframe_index = 2; const matrix_table_symbol_index = 3; const matrix_table_exchange_index = 4; $(document).ready(function() { init_select_filter(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/automations.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const displayFeedbackFormIfNecessary = () => { const feedbackFormData = $("#feedback-form-data"); if(feedbackFormData.data("display-form") === "True") { displayFeedbackForm( feedbackFormData.data("form-to-display"), feedbackFormData.data("user-id"), feedbackFormData.data("on-submit-url"), ); } }; const onEditorChange = (newValue) => { const update_url = $("button[data-role='saveConfig']").attr(update_url_attr); updateTentacleConfig(newValue, update_url); }; const startAutomations = () => { const successCallback = (updated_data, update_url, dom_root_element, msg, status) => { create_alert("success", "Automations started"); } const update_url = $("button[data-role='startAutomations']").attr(update_url_attr); send_and_interpret_bot_update(null, update_url, null, successCallback); } const updateAutomationsCount = (delta) => { if(configEditor === null){ return; } const automationsCount = configEditor.getEditor("root.automations_count"); const updatedValue = Number(automationsCount.getValue()) + delta; if(updatedValue < 0){ return; } automationsCount.setValue(String(updatedValue)); } const addAutomation = () => { updateAutomationsCount(1); } const removeAutomation = () => { updateAutomationsCount(-1); } if (!startTutorialIfNecessary("automations")){ displayFeedbackFormIfNecessary(); } addEditorChangeEventCallback(onEditorChange); $("button[data-role='startAutomations']").on("click", startAutomations); $("button[data-role='add-automation']").on("click", addAutomation); $("button[data-role='remove-automation']").on("click", removeAutomation); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/backtesting.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function get_selected_files(){ const selected_files = []; const selectedRows = dataFilesTable.rows( function ( idx, data, node ) { return $(node).find("input[type='checkbox']:checked").length > 0; } ).eq(0); if(selectedRows){ selectedRows.each(function( index ) { selected_files.push(dataFilesTable.row( index ).data()[6]); }); } return selected_files; } function handle_backtesting_buttons(){ $("#startBacktesting").click(function(){ $("#backtesting_progress_bar").show(); lock_interface(); const request = {}; request["files"] = get_selected_files(); if(check_date_range_available()){ if(!check_date_range()){ create_alert("error", "Invalid date range.", ""); return; } request["start_timestamp"] = startDate.val().length ? (new Date(startDate.val()).getTime()) : null; request["end_timestamp"] = endDate.val().length ? (new Date(endDate.val()).getTime()) : null; } const update_url = $("#startBacktesting").attr("start-url"); const run_on_common_part_only = syncDataOnlyCheckbox.is(":checked"); start_backtesting(request, `${update_url}&run_on_common_part_only=${run_on_common_part_only}`); }); } function handle_file_selection(){ const selectable_datafile = $(".selectable_datafile"); selectable_datafile.unbind('click'); selectable_datafile.click(function () { const row_element = $(this); if (row_element.hasClass(selected_item_class)){ row_element.removeClass(selected_item_class); row_element.find(".dataFileCheckbox").prop('checked', false); }else{ row_element.toggleClass(selected_item_class); const checkbox = row_element.find(".dataFileCheckbox"); const symbols = checkbox.attr("symbols"); const data_file = checkbox.attr("data-file"); checkbox.prop('checked', true); // uncheck same symbols from other rows if any $("#dataFilesTable").find("input[type='checkbox']:checked").each(function(){ if($(this).attr("symbols") === symbols && !($(this).attr("data-file") === data_file)){ $(this).closest('tr').removeClass(selected_item_class); $(this).prop('checked', false); } }); } if($("#dataFilesTable").find("input[type='checkbox']:checked").length > 1){ syncDataOnlyDiv.removeClass(hidden_class); }else{ syncDataOnlyDiv.addClass(hidden_class); } handle_date_selection(); lock_interface(false); }); } function check_date_range(){ const start_date = new Date($("#startDate").val()); const end_date = new Date($("#endDate").val()); return (!isNaN(start_date) && !isNaN(end_date)) ? start_date < end_date : true; } function check_date_range_available() { const data_file_checked = $(".selectable_datafile").has("input[type='checkbox']:checked"); return data_file_checked.length === data_file_checked.has("td[data-start-timestamp]").length; } function handle_date_selection(){ if(!check_date_range_available()){ startDate.prop("disabled", true); endDate.prop("disabled", true); return; } startDate.prop("disabled", false); endDate.prop("disabled", false); const data_file_checked_with_date_range = $(".selectable_datafile").has("input[type='checkbox']:checked") .has("td[data-end-timestamp]"); if(data_file_checked_with_date_range.length === 0){ return; } let end_timestamps = []; let start_timestamps = []; data_file_checked_with_date_range.find("[data-end-timestamp").each(function(){ end_timestamps.push(parseInt($(this).attr("data-end-timestamp"))); start_timestamps.push(parseInt($(this).attr("data-start-timestamp"))); }); const start_timestamp = syncDataOnlyCheckbox.prop("checked") ? Math.max(...start_timestamps) : Math.min(...start_timestamps); const end_timestamp = syncDataOnlyCheckbox.prop("checked") ? Math.min(...end_timestamps) : Math.max(...end_timestamps); const newStartDateTime = new Date(start_timestamp * 1000); const newEndDateTime = new Date(end_timestamp * 1000); const newStartDate = newStartDateTime.toISOString().split("T")[0]; const newEndDate = newEndDateTime.toISOString().split("T")[0]; if((new Date(startDate[0].value)) < newStartDateTime){ startDate.val(newStartDate); } if((new Date(endDate[0].value)) > newEndDateTime){ endDate.val(newEndDate); } startDate[0].min = newStartDate; startDate[0].max = newEndDate; endDate[0].max = newEndDate; endDate[0].min = newStartDate; } const dataFilesTable = $('#dataFilesTable').DataTable({ "order": [[ 2, 'desc' ]], "columnDefs": [ { "width": "20%", "targets": 2 }, { "width": "8%", "targets": 5 }, ], "destroy": true }); const syncDataOnlyDiv = $("#synchronized-data-only-div"); const syncDataOnlyCheckbox = $("#synchronized-data-only-checkbox"); const startDate = $("#startDate"); const endDate = $("#endDate"); $(document).ready(function() { lock_interface_callbacks.push(function () { return get_selected_files() <= 0; }); handle_backtesting_buttons(); handle_file_selection(); $('#dataFilesTable').on("draw.dt", function(){ handle_file_selection(); }); lock_interface(); init_backtesting_status_websocket(); syncDataOnlyCheckbox.on("change", handle_date_selection); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/commands.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function load_commands_metadata() { const feedbackButton = $("#feedbackButton"); if(feedbackButton.length > 0){ $.get({ url: feedbackButton.attr(update_url_attr), dataType: "json", success: function(msg, status){ if(msg) { feedbackButton.attr("href", msg); feedbackButton.removeClass("disabled"); }else{ setNoFeedback(feedbackButton); } }, error: function(result, status, error){ setNoFeedback(feedbackButton); window.console&&console.error("Impossible to get the current OctoBot feedback form: "+error); } }) } } function setNoFeedback(feedbackButton){ feedbackButton.text("No feedback system available for now"); } function update_metrics_option(){ const metrics_input = $("#metricsCheckbox"); function metrics_success_callback(updated_data, update_url, dom_root_element, msg, status) { if(updated_data){ create_alert("success", "Anonymous statistics enabled", "Thank you for supporting OctoBot development!"); }else{ create_alert("success", "Anonymous statistics disabled", ""); } } send_and_interpret_bot_update(metrics_input.is(':checked'), metrics_input.attr(update_url_attr), null, metrics_success_callback, update_failure_callback); } function update_beta_option(){ function beta_success_callback(updated_data, update_url, dom_root_element, msg, status) { const details = "Please restart your OctoBot for it to take effect." if(updated_data){ create_alert("success", "Beta environment enabled", details); }else{ create_alert("success", "Beta environment disabled", details); } } const beta_input = $("#beta-checkbox"); send_and_interpret_bot_update(beta_input.is(':checked'), beta_input.attr(update_url_attr), null, beta_success_callback, update_failure_callback); } function update_failure_callback(updated_data, update_url, dom_root_element, msg, status) { create_alert("error", msg.responseText, ""); } $(document).ready(function() { load_commands_metadata(); $("#metricsCheckbox").change(function(){ update_metrics_option(); }); $("#beta-checkbox").change(function(){ update_beta_option(); }); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/community.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function disablePackagesOperations(should_lock=true){ const disabled_attr = 'disabled'; $("[data-role=\"install-strategy\"]").prop(disabled_attr, should_lock); } function reloadTable(){ $('.table').each(function () { $(this).DataTable({ paging: false }); }); registerPackagesEvents(); } function registerPackagesEvents(){ $("[data-role=\"install-strategy\"]").click(function (){ const element = $(this); const update_url = element.attr(update_url_attr); const data = { "strategy_id": element.data("strategy-id"), "name": element.data("strategy-name"), "description": element.data("description"), }; disablePackagesOperations(); send_and_interpret_bot_update(data, update_url, element, packagesOperationSuccessCallback, packagesOperationErrorCallback); }); } function selectProfile(profileId) { if(profileId.length){ const changeProfileURL = $("#cloud-strategies-selector").data("select-profile-url").replace("PROFILE_ID", profileId); window.location.replace(changeProfileURL); } } function packagesOperationSuccessCallback(updated_data, update_url, dom_root_element, msg, status){ disablePackagesOperations(false); const postInstallActions = dom_root_element.data("post-install-action") if(postInstallActions === "select-profile"){ selectProfile(msg.profile_id) }else{ create_alert("success", "Strategy operation", msg.text); } } function packagesOperationErrorCallback(updated_data, update_url, dom_root_element, result, status, error){ disablePackagesOperations(false); create_alert("error", "Error during strategy operation: "+result.responseText, ""); } function displayBotSelectorWhenNoSelectedBot(){ if($("#bot-selector").find("button[data-role='selected-bot']").length === 0) { // no selected bot, force selection $('#bot-select-modal').modal({backdrop: 'static', keyboard: false}) } } function disableBotsSelectAndCreate(disabled){ $("#bot-selector").find("button[data-role='select-bot']").attr("disabled", disabled); $("#create-new-bot").attr("disabled", disabled); } function initBotsCallbacks(){ $("#bot-selector").find("button[data-role='select-bot']").click((element) => { const selectButton = $(element.target); const data = selectButton.data("bot-id") disableBotsSelectAndCreate(true); selectButton.html("") const update_url = $("#bot-selector").data("update-url"); send_and_interpret_bot_update(data, update_url, null, botOperationSuccessCallback, botOperationErrorCallback); }) $("#create-new-bot").click((element) => { const createButton = $(element.target); const update_url = createButton.data("update-url"); disableBotsSelectAndCreate(true); createButton.html(" Creating ...") send_and_interpret_bot_update({}, update_url, null, botOperationSuccessCallback, botOperationErrorCallback); }) } function botOperationSuccessCallback(updated_data, update_url, dom_root_element, result, status, error){ // reload the page to retest bots window.location.reload(); } function botOperationErrorCallback(updated_data, update_url, dom_root_element, result, status, error){ create_alert("error", "Error when managing bots: "+result.responseText, ""); } function initLoginSubmit(){ $("form[name=community-login]").on("submit", () => { $("input[type=submit]").addClass(hidden_class).attr("disabled", true); $("#login-waiter").removeClass(hidden_class); }); } $(document).ready(function() { reloadTable(); displayBotSelectorWhenNoSelectedBot(); initBotsCallbacks(); initLoginSubmit(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/community_metrics.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { $('.table').each(function () { $(this).DataTable(); }); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/config_tentacle.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function apply_evaluator_default_config(element) { const default_config = element.attr("default-elements").replace(new RegExp("'","g"),'"'); const update_url = $("#defaultConfigDiv").attr(update_url_attr); const updated_config = {}; const config_type = element.attr(config_type_attr); updated_config[config_type] = {}; $.each($.parseJSON(default_config), function (i, config_key) { updated_config[config_type][config_key] = "true"; }); updated_config["deactivate_others"] = true; // send update send_and_interpret_bot_update(updated_config, update_url, null, handle_apply_evaluator_default_config_success_callback); } function handle_apply_evaluator_default_config_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", "Evaluators activated", "Restart OctoBot for changes to be applied"); location.reload(); } function updateTentacleConfig(updatedConfig, update_url){ send_and_interpret_bot_update(updatedConfig, update_url, null, handle_tentacle_config_update_success_callback, handle_tentacle_config_update_error_callback); } function factory_reset(update_url){ send_and_interpret_bot_update(null, update_url, null, handle_tentacle_config_reset_success_callback, handle_tentacle_config_update_error_callback); } function handle_tentacle_config_reset_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", "Configuration reset", msg); initConfigEditor(false); } function handle_tentacle_config_update_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", "Configuration saved", msg); initConfigEditor(false); } function handle_tentacle_config_update_error_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("error", "Error when updating config", msg.responseText); } function handleConfigDisplay(success){ $("#editor-waiter").hide(); if(success){ $("#configErrorDetails").hide(); if(canEditConfig()) { $("#saveConfigFooter").show(); $("button[data-role='saveConfig']").removeClass(hidden_class).unbind("click").click(function (event) { const errorsDesc = validateJSONEditor(configEditor); if (errorsDesc.length) { create_alert("error", "Error when saving configuration", `Invalid configuration data: ${errorsDesc}.`); } else{ const url = $(event.currentTarget).attr(update_url_attr) updateTentacleConfig(configEditor.getValue(), url); } }); }else{ $("#noConfigMessage").show(); } }else{ $("#configErrorDetails").show(); } } function get_config_value_changed(element, new_value) { let new_value_str = new_value.toString().toLowerCase(); return new_value_str !== element.attr(config_value_attr).toLowerCase(); } function handle_save_buttons_success_callback(updated_data, update_url, dom_root_element, msg, status){ update_dom(dom_root_element, msg); create_alert("success", "Configuration successfully updated", "Restart OctoBot for changes to be applied."); } function send_command_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", `${updated_data.subject} command sent`, ""); } function handle_save_button(){ $("#saveActivationConfig").click(function() { const full_config = $("#activatedElementsBody"); const updated_config = {}; const update_url = $("#saveActivationConfig").attr(update_url_attr); full_config.find("."+config_element_class).each(function(){ const config_type = $(this).attr(config_type_attr); if(!(config_type in updated_config)){ updated_config[config_type] = {}; } const new_value = parse_new_value($(this)); const config_key = get_config_key($(this)); if(get_config_value_changed($(this), new_value)){ updated_config[config_type][config_key] = new_value; } }); // send update send_and_interpret_bot_update(updated_config, update_url, full_config, handle_save_buttons_success_callback); }) } function handleUserCommands(){ $(".user-command").click(function () { const button = $(this); const update_url = button.attr("update-url"); const commandData = {}; button.parents(".modal-content").find(".command-param").each(function (){ const element = $(this); commandData[element.data("param-name")] = element.val(); }) const data = { action: button.data("action"), subject: button.data("subject"), data: commandData, }; send_and_interpret_bot_update(data, update_url, null, send_command_success_callback); }); } function handleButtons() { handle_save_button(); handleUserCommands(); $("#applyDefaultConfig").click(function () { const tentacle_name = $(this).attr("tentacle"); apply_evaluator_default_config($("#" + tentacle_name)); }); $("#startBacktesting").click(function(){ if(!check_date_range()){ create_alert("error", "Invalid date range.", ""); return; } $("#backtesting_progress_bar").show(); lock_interface(); const request = {}; request["files"] = get_selected_files(); const startDate = $("#startDate"); const endDate = $("#endDate"); request["start_timestamp"] = startDate.val().length ? (new Date(startDate.val()).getTime()) : null; request["end_timestamp"] = endDate.val().length ? (new Date(endDate.val()).getTime()) : null; const update_url = $("#startBacktesting").attr("start-url"); start_backtesting(request, update_url); }); $("button[data-role='factoryResetConfig']").click(function(){ if (confirm("Reset this tentacle configuration to its default values ?") === true) { factory_reset($("button[data-role='factoryResetConfig']").attr("update-url")); } }); $("#reloadBacktestingPart").click(function () { window.location.hash = "backtestingInputPart"; location.reload(); }) } function check_date_range(){ const start_date = new Date($("#startDate").val()); const end_date = new Date($("#endDate").val()); return (!isNaN(start_date) && !isNaN(end_date)) ? start_date < end_date : true; } function get_config_key(elem){ return elem.attr(config_key_attr); } function parse_new_value(element) { return element.attr(current_value_attr).toLowerCase(); } function handle_evaluator_configuration_editor(){ $(".config-element").click(function(e){ if (isDefined($(e.target).attr(no_activation_click_attr))){ // do not trigger when click on items with no_activation_click_attr set return; } const element = $(this); if (element.hasClass(config_element_class)){ if (element[0].hasAttribute(config_type_attr) && (element.attr(config_type_attr) === evaluator_config_type || element.attr(config_type_attr) === trading_config_type)){ // build data update let new_value; let current_value = parse_new_value(element); if (current_value === "true"){ new_value = "false"; }else if(current_value === "false"){ new_value = "true"; } // update current value element.attr(current_value_attr, new_value); //update dom update_element_temporary_look(element); } } }); } function something_is_unsaved(){ let edited_config = canEditConfig() ? getValueChangedFromRef( configEditor.getValue(), savedConfig, true ) : false; return ( edited_config || $("#super-container").find("."+modified_badge).length > 0 ) } function get_selected_files(){ return [$("#dataFileSelect").val()]; } function canEditConfig() { return parsedConfigSchema && parsedConfigValue } let configEditor = null; let configEditorChangeEventCallbacks = []; let savedConfig = null; let parsedConfigSchema = null; let parsedConfigValue = null; let startingConfigValue = null; function _addGridDisplayOptions(schema){ if(typeof schema.properties === "undefined" && typeof schema.items === "undefined"){ return; } // display user inputs as grid // if(typeof schema.format === "undefined") { // schema.format = "grid"; // } if(typeof schema.options === "undefined"){ schema.options = {}; } schema.options.grid_columns = 6; if(typeof schema.properties !== "undefined"){ Object.values(schema.properties).forEach (property => { _addGridDisplayOptions(property) }); } if(typeof schema.items !== "undefined"){ _addGridDisplayOptions(schema.items) } } function initConfigEditor(showWaiter) { if(showWaiter){ $("#editor-waiter").show(); } const configEditorBody = $("#configEditorBody"); function editDetailsSuccess(updated_data, update_url, dom_root_element, msg, status){ const inputs = msg["displayed_elements"]["data"]["elements"]; if(inputs.length === 0){ handleConfigDisplay(true); return; } parsedConfigValue = msg["config"]; savedConfig = parsedConfigValue parsedConfigSchema = inputs[0]["schema"]; parsedConfigSchema.id = "tentacleConfig" if(configEditor !== null){ configEditor.destroy(); } if (canEditConfig()){ fix_config_values(parsedConfigValue, parsedConfigSchema) } _addGridDisplayOptions(parsedConfigSchema); const settingsRoot = $("#configEditor"); configEditor = canEditConfig() ? (new JSONEditor(settingsRoot[0],{ schema: parsedConfigSchema, startval: parsedConfigValue, no_additional_properties: true, prompt_before_delete: true, disable_array_reorder: true, disable_collapse: true, disable_properties: true, disable_edit_json: true, })) : null; settingsRoot.find("select[multiple=\"multiple\"]").select2({ width: 'resolve', // need to override the changed default closeOnSelect: false, placeholder: "Select values to use" }); const configEditorButtons = $("#configEditorButtons"); if(configEditor !== null){ configEditor.on("change", editorChangeCallback); if(configEditorButtons.length){ configEditorButtons.removeClass(hidden_class); } } else { if(configEditorButtons.length){ configEditorButtons.addClass(hidden_class); } } handleConfigDisplay(true); } const editDetailsFailure = (updated_data, update_url, dom_root_element, msg, status) => { create_alert("error", "Error when fetching tentacle config", msg.responseText); handleConfigDisplay(false); } send_and_interpret_bot_update(null, configEditorBody.data("edit-details-url"), null, editDetailsSuccess, editDetailsFailure, "GET"); } function editorChangeCallback(){ if(validateJSONEditor(configEditor) === "" && something_is_unsaved()){ configEditorChangeEventCallbacks.forEach((callback) => { callback(configEditor.getValue()); }); } } function addEditorChangeEventCallback(callback){ configEditorChangeEventCallbacks.push(callback) } $(document).ready(function() { initConfigEditor(true); handleButtons(); if(typeof lock_interface !== "undefined"){ lock_interface(false); } handle_evaluator_configuration_editor(); if(typeof init_backtesting_status_websocket !== "undefined"){ init_backtesting_status_websocket(); } register_exit_confirm_function(something_is_unsaved); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/configuration.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ const sidebarNavLinks = $(".sidebar").find(".nav-link[role='tab']:not(.dropdown-toggle)"); function handle_nested_sidenav(){ sidebarNavLinks.each(function (){ $(this).on("click",function (e){ e.preventDefault(); activate_tab($(this), sidebarNavLinks); }); }); } function get_tabs_config(){ return $(document).find("." + config_root_class + " ." + config_container_class); } function handle_reset_buttons(){ $("#reset-config").click(function() { reset_configuration_element(); }) } function handle_remove_buttons(){ // Card deck removing $(document).on("click", ".remove-btn", function() { const deleted_element_key = get_card_config_key($(this)); const deck = get_deck_container($(this)); const card = get_card_container($(this)); if ($.inArray(deleted_element_key, deleted_global_config_elements) === -1 && !card.hasClass(added_class)){ deleted_global_config_elements.push(deleted_element_key); } $(this).closest(".card").fadeOut("normal", function() { $(this).remove(); check_deck_modifications(deck); }); }); } function handle_buttons() { $("button[action=post]").each(function () { $(this).click(function () { send_and_interpret_bot_update(null, $(this).attr(update_url_attr), null, generic_request_success_callback, generic_request_failure_callback); }); }); } function check_deck_modifications(deck){ if(deck.find("."+added_class).length > 0 || deleted_global_config_elements.length > 0){ toogle_deck_container_modified(deck); }else{ toogle_deck_container_modified(deck, false); } } function handle_add_buttons(){ handleCardDecksAddButtons(); handleEditableAddButtons(); } function handleEditableAddButtons(){ $("button[data-role='editable-add']").click((jsElement) => { const button = $(jsElement.currentTarget); const parentContainer = button.parent(); const targetTemplate = parentContainer.find(`span[data-add-template-for='${button.attr("data-add-template-target")}']`); const selectedValue = button.data("default-key"); let newEditable = targetTemplate.html().replace(new RegExp("Empty","g"), selectedValue); button.before(newEditable); handle_editable(); register_edit_events(); }) } function handleEditableRenameIfNotAlready(e, params){ const element = $(e.target); // 0. update key-value config to use the new key const previousKey = element.text().trim(); let newKey = element.text().trim(); if(isDefined(params) && isDefined(params["newValue"])){ newKey = params["newValue"]; } const previousConfigKey = element.attr("data-label-for"); const valueToUpdate = element.parent().parent().find(`a[config-key=${previousConfigKey}]`); const newConfigKey = previousConfigKey.replace(new RegExp(previousKey,"g"), newKey); element.attr("data-label-for", newConfigKey) valueToUpdate.attr("config-key", newConfigKey) // 1. force change to the associated value to save it valueToUpdate.data("changed", true); // 2. add previous key to deleted values unless it's the default key deleted_global_config_elements.push(previousConfigKey); const card_container = get_card_container(element); toogle_card_modified(card_container, true); } function registerHandleEditableRenameIfNotAlready(element, events, handler){ if(typeof element.data("label-for") !== "undefined"){ events.forEach((event) => { if(!check_has_event_using_handler(element, event, handler)){ element.on(event, handler); } }) } } function handleCardDecksAddButtons(){ // Card deck adding $(".add-btn").click(function() { const button_id = $(this).attr("id"); const deck = $(this).parents("." + config_root_class).find(".card-deck"); const select_input = $("#" + button_id + "Select"); let select_value = select_input.val(); // currencies const currencyDetails = currencyDetailsById[select_value]; let select_symbol = ""; let currency_id = undefined; if(isDefined(currencyDetails)){ currency_id = select_value; select_value = currencyDetails.n; select_symbol = currencyDetails.s } // exchanges let has_websockets = false; const ws_attr = select_input.find("[data-tokens='"+select_value+"']").attr("data-ws"); if(isDefined(ws_attr)){ has_websockets = ws_attr === "True"; } const editable_selector = "select[editable_config_id=\"multi-select-element-" + select_value + "\"]:first"; let target_template = $("#" + button_id + "-template-default"); //services const in_services = button_id === "AddService"; if (in_services){ target_template = $("#" + button_id + "-template-default-"+select_value); } // check if not already added if(deck.find("div[name='"+select_value+"']").length === 0){ let template_default = target_template.html().replace(new RegExp(config_default_value,"g"), select_value); template_default = template_default.replace(new RegExp("card-text symbols default","g"), "card-text symbols"); template_default = template_default.replace(new RegExp("card-img-top currency-image default","g"), "card-img-top currency-image"); if(isDefined(currency_id)){ template_default = template_default.replace(new RegExp(`data-currency-id="${config_default_value.toLowerCase()}"`), `data-currency-id="${currency_id}"`); } if(has_websockets){ // all exchanges cards template_default = template_default.replace(new RegExp("data-role=\"websocket-mark\" class=\"d-none "), "data-role=\"websocket-mark\" class=\""); } deck.append(template_default).hide().fadeIn(); handle_editable(); // select options with reference market if any $(editable_selector).each(function () { if ( $(this).siblings('.select2').length === 0 && !$(this).parent().hasClass('default') ){ $(this).find("option").each(function () { const option = $(this); const symbols = option.attr("value").split("/"); const reference_market = select_input.attr("reference_market").toUpperCase(); if (symbols[0] === select_symbol && symbols[1] === reference_market){ option.attr("selected", "selected"); } // remove options without this currency symbol if (!(symbols[0] === select_symbol || symbols[1] === select_symbol)){ option.detach(); } }); } }); let placeholder = ""; if(select_symbol){ placeholder = "Select trading pair(s)"; }else if(in_services){ // telegram is the only service with a select2 element placeholder = "Add user(s) in whitelist"; } // add select2 selector $(editable_selector).each(function () { if ( $(this).siblings('.select2').length === 0 && !$(this).parent().hasClass('default') ) { $(this).select2({ width: 'resolve', // need to override the changed default tags: true, placeholder: placeholder, }); } }); toogle_deck_container_modified(get_deck_container($(this))); // refresh images if required fetch_images(); handleDefaultImages(); register_edit_events(); } }); } function handle_special_values(currentElem){ if (currentElem.is(traderSimulatorCheckbox) || currentElem.is(traderCheckbox)){ if (currentElem.is(":checked")){ const otherElem = currentElem.is(traderCheckbox) ? traderSimulatorCheckbox : traderCheckbox; otherElem.prop('checked', false); otherElem.trigger("change"); } } else if(currentElem.is(tradingReferenceMarket)) { display_generic_modal("Change reference market", "Do you want to adapt the reference market for all your configured pairs ?", "", function () { let url = "/api/change_reference_market_on_config_currencies"; let data = {}; data["old_base_currency"] = tradingReferenceMarket.attr(config_value_attr); data["new_base_currency"] = tradingReferenceMarket.text(); send_and_interpret_bot_update(data, url, null, generic_request_success_callback, generic_request_failure_callback); }, null); } else if(currentElem.data("summary-field") === "radio-select"){ currentElem.find('input[type="radio"]').each((index, element) => { const parsedElement = $(element); if(parsedElement.is(":checked")){ currentElem.attr("current-value", parsedElement.attr("value")); } }) } } function register_edit_events(){ $('.config-element').each(function (){ const element = $(this); if(typeof element.data("label-for") === "undefined"){ add_event_if_not_already_added(element, 'save', card_edit_handler); add_event_if_not_already_added(element, 'change', card_edit_handler); }else{ registerHandleEditableRenameIfNotAlready(element, ['save', 'change'], handleEditableRenameIfNotAlready) } }); register_exchanges_checks(false); } function card_edit_handler(e, params){ const current_elem = $(this); handle_special_values(current_elem); let new_value = parse_new_value(current_elem); if(isDefined(params) && isDefined(params["newValue"])){ new_value = params["newValue"]; } const config_key = get_config_key(current_elem); const card_container = get_card_container(current_elem); const other_config_elements = card_container.find("."+config_element_class); let something_changed = get_config_value_changed(current_elem, new_value, config_key); if(!something_changed){ // if nothing changed on the current field, check other fields of the card $.each(other_config_elements, function () { if ($(this)[0] !== current_elem[0]){ var elem_new_value = parse_new_value($(this)); var elem_config_key = get_config_key($(this)); something_changed = something_changed || get_config_value_changed($(this), elem_new_value, elem_config_key); } }); } toogle_card_modified(card_container, something_changed); } function something_is_unsaved(){ const config_root = $("#super-container"); return ( config_root.find("."+card_class_modified).length > 0 || config_root.find("."+deck_container_modified_class).length > 0 || config_root.find("."+modified_badge).length > 0 ) } function parse_new_value(element){ const raw_data = element.text().trim(); // simple case if(element[0].hasAttribute(current_value_attr)){ const value = element.attr(current_value_attr).trim(); if(element[0].hasAttribute(config_data_type_attr)){ switch(element.attr(config_data_type_attr)) { case "bool": return value === true || value === "true"; case "number": return Number(value); default: return value; } }else{ return value; } } // with data type else if(element[0].hasAttribute(config_data_type_attr)){ switch(element.attr(config_data_type_attr)) { case "bool": return element.is(":checked"); case "list": const new_value = []; element.find(":selected").each(function(index, value){ new_value.splice(index, 0, value.text.trim()); }); return new_value; case "number": return Number(raw_data); default: return raw_data; } // without information }else{ return raw_data; } } function _save_config(element, restart_after_save) { const full_config = $("#super-container"); const updated_config = {}; const update_url = element.attr(update_url_attr); // take all tabs into account get_tabs_config().each(function(){ $(this).find("."+config_element_class).each(function(){ const configElement = $(this) if(configElement.parent().parent().hasClass(hidden_class) || typeof configElement.attr("data-label-for") !== "undefined"){ // do not add hidden elements (add templates) // do not add element labels return } const config_type = configElement.attr(config_type_attr); if(config_type !== evaluator_list_config_type) { if (!(config_type in updated_config)) { updated_config[config_type] = {}; } const new_value = parse_new_value(configElement); const config_key = get_config_key(configElement); if (get_config_value_changed(configElement, new_value, config_key) && !config_key.endsWith("_Empty")) { updated_config[config_type][config_key] = new_value; } } }) }); // take removed elements into account updated_config["removed_elements"] = deleted_global_config_elements; updated_config["restart_after_save"] = restart_after_save; // send update send_and_interpret_bot_update(updated_config, update_url, full_config, handle_save_buttons_success_callback); } function handle_save_buttons(){ $("#save-config").click(function() { _save_config($(this), false); }) $("#save-config-and-restart").click(function() { _save_config($(this), true); }) } function get_config_key(elem){ return elem.attr(config_key_attr); } function get_card_config_key(card_component, config_type="global_config"){ const element_with_config = card_component.parent(".card-body"); return get_config_key(element_with_config); } function get_deck_container(elem) { return elem.parents("."+deck_container_class); } function get_card_container(elem) { return elem.parents("."+config_card_class); } function get_config_value_changed(element, new_value, config_key) { let new_value_str = new_value.toString(); if(new_value instanceof Array && new_value.length > 0){ //need to format array to match python string representation of config var str_array = []; $.each(new_value, function(i, val) { str_array.push("'"+val+"'"); }); new_value_str = "[" + str_array.join(", ") + "]"; } return get_value_changed(new_value_str, element.attr(config_value_attr).trim(), config_key) || element.data("changed") === true; } function get_value_changed(new_val, dom_conf_val, config_key){ const lower_case_val = new_val.toLowerCase(); if(is_different_value(new_val, lower_case_val, dom_conf_val)){ // only push update if the new value is not the previously updated one if (has_element_already_been_updated(config_key)) { return !has_update_already_been_applied(lower_case_val, config_key); } return true; }else{ // nothing changed in DOM but the previously updated value might be different (ex: back on initial value) // only push update if the new value is not the previously updated one if (has_element_already_been_updated(config_key)) { return !has_update_already_been_applied(lower_case_val, config_key); } return false; } } function is_different_value(new_val, lower_case_new_val, dom_conf_val){ return !(lower_case_new_val === dom_conf_val.toLowerCase() || ((Number(new_val) === Number(dom_conf_val) && $.isNumeric(new_val)))); } function has_element_already_been_updated(config_key){ return config_key in validated_updated_global_config; } function has_update_already_been_applied(lower_case_val, config_key){ return lower_case_val === validated_updated_global_config[config_key].toString().toLowerCase(); } function handle_save_buttons_success_callback(updated_data, update_url, dom_root_element, msg, status){ updated_validated_updated_global_config(msg["global_updated_config"]); update_dom(dom_root_element, msg); create_alert("success", "Configuration successfully updated", "Restart OctoBot for changes to be applied."); } function apply_evaluator_default_config(element) { const default_config = element.attr("default-elements").replace(new RegExp("'","g"),'"'); const update_url = $("#save-config").attr(update_url_attr); const updated_config = {}; const config_type = element.attr(config_type_attr); updated_config[config_type] = {}; $.each($.parseJSON(default_config), function (i, config_key) { updated_config[config_type][config_key] = "true"; }); updated_config["deactivate_others"] = true; // send update send_and_interpret_bot_update(updated_config, update_url, null, handle_apply_evaluator_default_config_success_callback); } function handle_apply_evaluator_default_config_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", "Evaluators activated", "Restart OctoBot for changes to be applied"); } function other_element_activated(root_element){ let other_activated_modes_count = root_element.children("."+success_list_item).length; return other_activated_modes_count > 1; } function deactivate_other_elements(element, root_element) { const element_id = element.attr("id"); root_element.children("."+success_list_item).each(function () { const element = $(this); if(element.attr("id") !== element_id){ element.attr(current_value_attr, "false"); update_element_temporary_look(element); } }) } function updateTradingModeSummary(selectedElement){ const elementDocModal = $(`#${selectedElement.attr("name")}Modal`); const elementDoc = elementDocModal.find(".modal-body").text().trim(); const blocks = elementDoc.trim().split(".\n"); let summaryBlocks = `${blocks[0]}.`; if (summaryBlocks.length < 80 && blocks.length > 1){ summaryBlocks = `${summaryBlocks} ${blocks[1]}.`; } $("#selected-trading-mode-summary").html(summaryBlocks); } function updateStrategySelector(required_elements){ const noStrategyInfo = $("#no-strategy-info"); const strategyConfig = $("#evaluator-config-root"); const strategyConfigFooter = $("#evaluator-config-root-footer"); if (required_elements.length > 1) { noStrategyInfo.addClass(hidden_class); strategyConfig.removeClass(hidden_class); strategyConfigFooter.removeClass(hidden_class); } else { noStrategyInfo.removeClass(hidden_class); strategyConfig.addClass(hidden_class); strategyConfigFooter.addClass(hidden_class); } } function update_requirement_activation(element) { const required_elements = element.attr("requirements").split("'"); const default_elements = element.attr("default-elements").split("'"); $("#evaluator-config-root").children(".config-element").each(function () { const element = $(this); if(required_elements.indexOf(element.attr("id")) !== -1){ if(default_elements.indexOf(element.attr("id")) !== -1){ element.attr(current_value_attr, "true"); } update_element_temporary_look(element); update_element_required_marker_and_usability(element, true); }else{ element.attr(current_value_attr, "false"); update_element_temporary_look(element); update_element_required_marker_and_usability(element, false); } }); updateStrategySelector(required_elements); } function get_activated_strategies_count() { return $("#evaluator-config-root").children("."+success_list_item).length } function get_activated_trading_mode_min_strategies(){ const activated_trading_modes = $("#trading-modes-config-root").children("."+success_list_item); if(activated_trading_modes.length > 0) { return parseInt(activated_trading_modes.attr("requirements-min-count")); }else{ return 1; } } function check_evaluator_configuration() { const trading_modes = $("#trading-modes-config-root"); if(trading_modes.length) { const activated_trading_modes = trading_modes.children("." + success_list_item); if (activated_trading_modes.length) { const required_elements = activated_trading_modes.attr("requirements").split("'"); let at_least_one_activated_element = false; $("#evaluator-config-root").children(".config-element").each(function () { const element = $(this); if (required_elements.indexOf(element.attr("id")) !== -1) { at_least_one_activated_element = true; update_element_required_marker_and_usability(element, true); } else { update_element_required_marker_and_usability(element, false); } }); if (required_elements.length > 1 && !at_least_one_activated_element) { create_alert("error", "Trading modes require at least one strategy to work properly, please activate the " + "strategy(ies) you want for the selected mode.", ""); } updateStrategySelector(required_elements); updateTradingModeSummary(activated_trading_modes); } else { create_alert("error", "No trading mode activated, OctoBot need at least one trading mode.", ""); } } } function handle_activation_configuration_editor(){ $(".config-element").click(function(e){ if (isDefined($(e.target).attr(no_activation_click_attr))){ // do not trigger when click on items with no_activation_click_attr set return; } const element = $(this); if (element.hasClass(config_element_class) && ! element.hasClass(disabled_class)){ if (element[0].hasAttribute(config_type_attr)) { if(element.attr(config_type_attr) === evaluator_config_type || element.attr(config_type_attr) === trading_config_type || element.attr(config_type_attr) === tentacles_config_type) { const is_strategy = element.attr(config_type_attr) === evaluator_config_type; const is_trading_mode = element.attr(config_type_attr) === trading_config_type; const is_tentacle = element.attr(config_type_attr) === tentacles_config_type; const allow_only_one_activated_element = is_trading_mode || is_tentacle; // build data update let new_value = parse_new_value(element); let current_value; try { current_value = element.attr(current_value_attr).toLowerCase(); } catch (e) { current_value = element.attr(current_value_attr); } let root_element = $("#trading-modes-config-root"); if (is_tentacle){ root_element = element.parent(".config-container"); } if (current_value === "true") { if (allow_only_one_activated_element && !other_element_activated(root_element)) { create_alert("error", "Impossible to disable all options.", ""); return; } else if (is_strategy) { // strategy const min_strategies = get_activated_trading_mode_min_strategies(); if (get_activated_strategies_count() <= min_strategies) { create_alert("error", "This trading mode requires at least " + min_strategies + " activated strategies.", ""); return; } } new_value = "false"; } else if (current_value === "false") { new_value = "true"; if (allow_only_one_activated_element) { deactivate_other_elements(element, root_element); } } if (is_trading_mode) { update_requirement_activation(element); updateTradingModeSummary(element); } // update current value element.attr(current_value_attr, new_value); //update dom update_element_temporary_look(element); } else if (element.attr(config_type_attr) === evaluator_list_config_type){ const strategy_name = element.attr("tentacle"); apply_evaluator_default_config($("a[name='"+strategy_name+"']")); } } } }); } function handle_import_currencies(){ $("#import-currencies-button").on("click", function(){ $("#import-currencies-input").click(); }); $("#import-currencies-input").on("change", function () { var GetFile = new FileReader(); GetFile.onload = function(){ let update_url = $("#import-currencies-button").attr(update_url_attr); let data = {}; data["action"] = "update"; data["currencies"] = JSON.parse(GetFile.result); send_and_interpret_bot_update(data, update_url, null, handle_save_buttons_success_callback, generic_request_failure_callback); }; GetFile.readAsText(this.files[0]); }); } function handle_export_currencies_button(){ $("#export-currencies-button").on("click", function(){ update_url = $("#export-currencies-button").attr(update_url_attr); $.get(update_url, null, function(data, status){ download_data(JSON.stringify(data), "currencies_export.json"); }); }); } function reset_configuration_element(){ remove_exit_confirm_function(); location.reload(); } function updated_validated_updated_global_config(updated_data){ for (const conf_key in updated_data) { validated_updated_global_config[conf_key] = updated_data[conf_key]; } const to_del_attr = []; $.each(deleted_global_config_elements, function (i, val) { for (const attribute in validated_updated_global_config) { if(attribute.startsWith(val)){ to_del_attr.push(attribute); } } }); $.each(to_del_attr, function (i, val) { delete validated_updated_global_config[val]; }); deleted_global_config_elements = []; } function fetch_currencies(){ const maxDisplayedOptions = 2000; // display only the first 2000 options to avoid select performance issues const getCurrencyOption = (addCurrencySelect, details) => { return new Option(`${details.n} - ${details.s}`, details.i, false, false); } if(!$("#AddCurrencySelect").length){ return } $.get({ url: $("#AddCurrencySelect").data("fetch-url"), dataType: "json", success: function (data) { const addCurrencySelect = $("#AddCurrencySelect"); const options = []; data.slice(0, maxDisplayedOptions).forEach((element) => { if(!currencyDetailsById.hasOwnProperty(element.i)){ currencyDetailsById[element.i] = element } options.push(getCurrencyOption(addCurrencySelect, element)) }); addCurrencySelect.append(...options); // add selectpicker class at the last moment to avoid refreshing any existing one (slow) addCurrencySelect.addClass("selectpicker") addCurrencySelect.selectpicker('render'); // paginatedSelect2(addCurrencySelect, options, pageSize) }, error: function (result, status) { window.console && console.error(`Impossible to get currency list: ${result.responseText} (${status})`); } }); } let validated_updated_global_config = {}; let deleted_global_config_elements = []; let currencyDetailsById = {} const traderSimulatorCheckbox = $("#trader-simulator_enabled"); const traderCheckbox = $("#trader_enabled"); const tradingReferenceMarket = $("#trading_reference-market"); $(document).ready(function() { handle_nested_sidenav(); selectFirstTab(sidebarNavLinks); fetch_currencies(); setup_editable(); handle_editable(); handle_reset_buttons(); handle_save_buttons(); handle_add_buttons(); handle_remove_buttons(); handle_buttons(); handle_activation_configuration_editor(); handle_import_currencies(); handle_export_currencies_button(); register_edit_events(); register_exit_confirm_function(something_is_unsaved); check_evaluator_configuration(); register_exchanges_checks(true); startTutorialIfNecessary("profile"); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/dashboard.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function () { const handleAnnouncementsHide = () => { $("button[data-role=\"hide-announcement\"]").click(async (event) => { const source = $(event.currentTarget); const url = source.data("url"); await async_send_and_interpret_bot_update(undefined, url); }) } function _refresh_profitability(socket) { socket.emit('profitability'); waiting_profitability_update = false; } function handle_profitability(socket) { socket.on("profitability", function (data) { updateProfitabilityDisplay( data["bot_real_profitability"], data["bot_real_flat_profitability"], data["bot_simulated_profitability"], data["bot_simulated_flat_profitability"], ); if (!waiting_profitability_update) { // re-schedule profitability refresh waiting_profitability_update = true; setTimeout(function () { _refresh_profitability(socket); }, profitability_update_interval); } }) } const updateProfitabilityDisplay = ( bot_real_profitability, bot_real_flat_profitability, bot_simulated_profitability, bot_simulated_flat_profitability ) => { if(isDefined(bot_real_profitability)){ displayProfitability(bot_real_profitability, bot_real_flat_profitability); } else if(isDefined(bot_simulated_profitability)){ displayProfitability(bot_simulated_profitability, bot_simulated_flat_profitability); } } const displayProfitability = (profitabilityValue, flatValue) => { const displayedValue = parseFloat(profitabilityValue.toFixed(2)); const badge = $("#profitability-badge"); const flatValueSpan = $("#flat-profitability"); const flatValueText = $("#flat-profitability-text"); const displayValue = $("#profitability-value"); badge.removeClass(hidden_class); flatValueSpan.removeClass(hidden_class); if(profitabilityValue < 0){ displayValue.text(displayedValue); flatValueText.text(flatValue); badge.addClass("badge-warning"); badge.removeClass("badge-success"); } else { displayValue.text(`+${displayedValue}`); flatValueText.text(`+${flatValue}`); badge.removeClass("badge-warning"); badge.addClass("badge-success"); } } function get_in_backtesting_mode() { return $("#first_symbol_graph").attr("backtesting_mode") === "True"; } function init_dashboard_websocket() { socket = get_websocket("/dashboard"); } function get_version_upgrade() { const upgradeVersionAlertDiv = $("#upgradeVersion"); if(upgradeVersionAlertDiv.length){ $.get({ url: upgradeVersionAlertDiv.attr(update_url_attr), dataType: "json", success: function (msg, status) { if (msg) { upgradeVersionAlertDiv.text(msg); upgradeVersionAlertDiv.parent().parent().removeClass(disabled_item_class); } } }) } } const onGraphUpdate = (data) => { if (onGraphUpdateCallback !== undefined){ onGraphUpdateCallback(); } update_graph(data); } function handle_graph_update() { socket.on('candle_graph_update_data', function (data) { onGraphUpdate(data); }); socket.on('new_data', function (data) { debounce( () => update_graph(data, false), 500 ); }); socket.on('error', function (data) { if ("missing exchange manager" === data) { socket.off("candle_graph_update_data"); socket.off("new_data"); socket.off("error"); socket.off("profitability"); $('#exchange-specific-data').load(document.URL + ' #exchange-specific-data', function (data) { init_graphs(); }); } }); } function _find_symbol_details(symbol, exchange_id) { let found_update_detail = undefined; update_details.forEach((update_detail) => { if (update_detail.symbol.replace(new RegExp("/","g"), "|") === symbol.replace(new RegExp("/","g"), "|") && update_detail.exchange_id === exchange_id) { found_update_detail = update_detail; } }) return found_update_detail; } function update_graph(data, re_update = true) { const candle_data = data.data; let update_detail = undefined; if (isDefined(data.request)) { update_detail = data.request; // ensure candles are from the right timeframe const client_update_detail = _find_symbol_details(candle_data.symbol, candle_data.exchange_id); if(typeof client_update_detail !== "undefined" && update_detail.time_frame !== client_update_detail.time_frame){ // wrong time frame: don't update and don't ask for more update return } } else { update_detail = _find_symbol_details(candle_data.symbol, candle_data.exchange_id); } if (isDefined(update_detail)) { get_symbol_price_graph(update_detail.elem_id, update_details.exchange_id, "", "", update_details.time_frame, shouldDisplayOrders(), get_in_backtesting_mode(), false, true, 0, candle_data); if (re_update) { setTimeout(function () { socket.emit("candle_graph_update", update_detail); }, price_graph_update_interval); } } } function init_updater(exchange_id, symbol, time_frame, elem_id) { if (!get_in_backtesting_mode()) { let update_detail = _find_symbol_details(symbol, exchange_id); if(typeof update_detail === "undefined"){ update_detail = {}; update_detail.exchange_id = exchange_id; update_detail.symbol = symbol; update_detail.time_frame = time_frame; update_detail.elem_id = elem_id; update_details.push(update_detail); }else{ update_detail.time_frame = time_frame; } setTimeout(function () { if (isDefined(socket)) { socket.emit("candle_graph_update", update_detail); } }, 3000); } } function enable_default_graph(time_frame) { $("#first_symbol_graph").removeClass(hidden_class); Plotly.purge("graph-symbol-price"); $("#graph-symbol-price").empty(); get_first_symbol_price_graph("graph-symbol-price", get_in_backtesting_mode(), init_updater, time_frame, shouldDisplayOrders()); } function no_data_for_graph(element_id) { document.getElementById(element_id).parentElement.classList.add(hidden_class); if ($(".candle-graph").not(`.${hidden_class}`).length === 0) { // enable default graph if no watched symbol graph can be displayed enable_default_graph(); } } function init_graphs() { update_details = []; updatePriceGraphs(); handle_graph_update(socket); handle_profitability(socket); } const shouldDisplayOrders = () => { return $("#displayOrderToggle").is(":checked"); } const updatePriceGraphs = () => { let useDefaultGraph = true; const time_frame = $("#timeFrameSelect").val(); $(".watched-symbol-graph").each(function () { useDefaultGraph = false; const element = $(this); Plotly.purge(element.attr("id")); element.empty(); get_watched_symbol_price_graph(element, init_updater, no_data_for_graph, time_frame, shouldDisplayOrders()); }); if (useDefaultGraph) { enable_default_graph(time_frame); } } const updateDisplayTimeFrame = (timeFrame) => { const url = $("#timeFrameSelect").data("update-url"); const request = { time_frame: timeFrame, } send_and_interpret_bot_update(request, url, null, undefined, generic_request_failure_callback); } const updateDisplayOrders = (display_orders) => { const url = $("#displayOrderToggle").data("update-url"); const request = { display_orders: display_orders, } send_and_interpret_bot_update(request, url, null, undefined, generic_request_failure_callback); } const registerConfigUpdates = () => { $("#timeFrameSelect").on("change", () => { updateDisplayTimeFrame($("#timeFrameSelect").val()) updatePriceGraphs(); }) $("#displayOrderToggle").on("change", () => { updateDisplayOrders(shouldDisplayOrders()); updatePriceGraphs(); }) } let update_details = []; let waiting_profitability_update = false; let socket = undefined; get_version_upgrade(); init_dashboard_websocket(); init_graphs(); registerConfigUpdates(); handleAnnouncementsHide(); }); let onGraphUpdateCallback = undefined function registerGraphUpdateCallback(callback) { onGraphUpdateCallback = callback } ================================================ FILE: Services/Interfaces/web_interface/static/js/components/dashboard_tutorial_starter.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function () { const displayFeedbackFormIfNecessary = () => { const feedbackFormData = $("#feedback-form-data"); if(feedbackFormData.data("display-form") === "True") { displayFeedbackForm( feedbackFormData.data("form-to-display"), feedbackFormData.data("user-id"), feedbackFormData.data("on-submit-url"), ); } }; if(!startTutorialIfNecessary("home", displayFeedbackFormIfNecessary)){ displayFeedbackFormIfNecessary() } }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/data_collector.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function handle_data_files_buttons(){ $(".delete_data_file").unbind('click'); $('.delete_data_file').click(function () { const request = $(this).attr("data-file"); const update_url = $("#dataFilesTable").attr(update_url_attr); send_and_interpret_bot_update(request, update_url, $(this), delete_success_callback, delete_error_callback) }); } function handle_file_selection(){ const input_elem = $('#inputFile'); const file_name = input_elem.val().split('\\').pop(); $('#inputFileLabel').html(file_name); const has_valid_name = file_name.indexOf(".data") !== -1; $('#importFileButton').attr('disabled', !has_valid_name); } function delete_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", msg, ""); dataFilesTable.row( dom_root_element.parents('tr') ) .remove() .draw(); } function delete_error_callback(updated_data, update_url, dom_root_element, result, status, error){ create_alert("error", result.responseText, ""); } function reload_table(){ $("#collector_data").load(location.href.split("?")[0] + " #collector_data",function(){ dataFilesTable = $('#dataFilesTable').DataTable({ "order": [], "columnDefs": [ { "width": "20%", "targets": 1 }, { "width": "8%", "targets": 4 }, ], }); handle_data_files_buttons(); dataFilesTable.on("draw.dt", function(){ handle_data_files_buttons(); }); }); } function start_collector(){ lock_collector_ui(); const request = {}; request["exchange"] = $('#exchangeSelect').val(); request["symbols"] = $('#symbolsSelect').val(); request["time_frames"] = $('#timeframesSelect').val().length ? $('#timeframesSelect').val() : null; request["startTimestamp"] = is_full_candle_history_exchanges() ? (new Date($("#startDate").val()).getTime()) : null; request["endTimestamp"] = is_full_candle_history_exchanges() ? (new Date($("#endDate").val()).getTime()) : null; const update_url = $("#collect_data").attr(update_url_attr); send_and_interpret_bot_update(request, update_url, $(this), collector_success_callback, collector_error_callback); } function stop_collector(){ const update_url = $("#stop_collect_data").attr(update_url_attr); send_and_interpret_bot_update({}, update_url, $(this), collector_success_callback, collector_error_callback); } function collector_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", msg, ""); reload_table(); } function collector_error_callback(updated_data, update_url, dom_root_element, result, status, error){ create_alert("error", result.responseText, ""); lock_collector_ui(false); } function display_alert(success, message){ if(success === "True"){ create_alert("success", message, ""); }else{ create_alert("error", message, ""); } } function update_symbol_list(url, exchange){ const data = {exchange: exchange}; $.get(url, data, function(data, status){ const symbolSelect = $("#symbolsSelect"); symbolSelect.empty(); // remove old options const symbolSelectBox = symbolSelect[0]; $.each(data, function(key,value) { symbolSelectBox.append(new Option(value,value)); }); symbolSelect.trigger('change'); }); } function update_available_timeframes_list(url, exchange){ const data = {exchange: exchange}; $.get(url, data, function(data, status){ const timeframeSelect = $("#timeframesSelect"); timeframeSelect.empty(); // remove old options const timeframeSelectBox = timeframeSelect[0]; $.each(data, function(key,value) { timeframeSelectBox.append(new Option(value,value)); }); timeframeSelect.trigger('change'); }); } function check_date_input(){ const startDate = new Date($("#startDate").val()); const enddate = new Date($("#endDate").val()); const startDateMax = new Date( $("#startDate")[0].max); const endDateMin = new Date( $("#endDate")[0].min); if(isNaN(startDate) && isNaN(enddate)){ return true; }else if (!isNaN(enddate) && isNaN(startDate)){ create_alert("error", "You should specify a start date.", ""); return false; }else if((!isNaN(startDate) && startDate > startDateMax) || (!isNaN(enddate) && enddate < endDateMin)){ create_alert("error", "Invalid date range.", ""); return false; }else{ return true; } } function is_full_candle_history_exchanges(){ const full_history_exchanges = $('#exchangeSelect > optgroup')[0].children; const selected_exchange = $('#exchangeSelect').find(":selected")[0]; return $.inArray(selected_exchange, full_history_exchanges) !== -1; } let dataFilesTable = $('#dataFilesTable').DataTable({"order": [[ 1, 'desc' ]]}); function handleSelects(){ createSelect2(); $('#exchangeSelect').on('change', function() { update_symbol_list($('#symbolsSelect').attr(update_url_attr), $('#exchangeSelect').val()); update_available_timeframes_list($('#timeframesSelect').attr(update_url_attr), $('#exchangeSelect').val()); is_full_candle_history_exchanges() ? $("#collector_date_range").show() : $("#collector_date_range").hide(); }); $('#collect_data').click(function(){ if(check_date_input()){ start_collector(); } }); $('#stop_collect_data').click(function(){ stop_collector(); }); $('#inputFile').on('change',function(){ handle_file_selection(); }); $("#endDate").on('change', function(){ let endDate = new Date(this.value); if(!isNaN(endDate)){ const endDateMax = new Date(); endDateMax.setDate(endDateMax.getDate() - 1); endDate.setDate(endDate.getDate() - 1); if(endDate > endDateMax){ this.value = endDateMax.toISOString().split("T")[0]; endDate = endDateMax; } $("#startDate")[0].max = endDate.toISOString().split("T")[0]; } }); $("#startDate").on('change', function(){ const startDate = new Date(this.value); if(!isNaN(startDate)){ const startDateMax = new Date(); startDateMax.setDate(startDateMax.getDate() - 2); startDate.setDate(startDate.getDate() + 1); $("#endDate")[0].min = startDate.toISOString().split("T")[0]; } }); const endDateMax = new Date(); endDateMax.setDate(endDateMax.getDate() - 1); $("#endDate")[0].max = endDateMax.toISOString().split("T")[0]; const startDateMax = new Date(); startDateMax.setDate(startDateMax.getDate() - 2); $("#startDate")[0].max = startDateMax.toISOString().split("T")[0]; } function createSelect2(){ $("#symbolsSelect").select2({ closeOnSelect: false, placeholder: "Symbol" }); $("#timeframesSelect").select2({ closeOnSelect: false, placeholder: "All Timeframes" }); } $(document).ready(function() { handle_data_files_buttons(); is_full_candle_history_exchanges() ? $("#collector_date_range").show() : $("#collector_date_range").hide(); $('#importFileButton').attr('disabled', true); dataFilesTable.on("draw.dt", function(){ handle_data_files_buttons(); }); handleSelects(); DataCollectorDoneCallbacks.push(reload_table); init_data_collector_status_websocket(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/dsl_help.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const fetchDSLKeywordsIfPossible = async () => { const dslTableBody = $("#dsl-keywords-table-body"); if(dslTableBody.length === 0 || dslTableBody.length === 0){ return; } const url = dslTableBody.data("update-url"); const response = await async_send_and_interpret_bot_update(undefined, url, null, "GET"); response.forEach(keywordData => { dslTableBody.append(`${keywordData.name}${keywordData.description}${keywordData.example}${keywordData.type}`); }); $("#dsl-keywords-table").DataTable({ "pageLength": 50, "order": [[ 3, "desc" ], [ 0, "asc" ]], }); } fetchDSLKeywordsIfPossible(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/evaluator_configuration.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function get_tabs_config(){ return $(document).find("." + config_root_class + " ." + config_container_class); } function handle_reset_buttons(){ $("#reset-config").click(function() { reset_configuration_element(); }) } function something_is_unsaved(){ const config_root = $("#super-container"); return ( config_root.find("."+card_class_modified).length > 0 || config_root.find("."+deck_container_modified_class).length > 0 || config_root.find("."+primary_badge).length > 0 ) } function parse_new_value(element){ const raw_data = replace_spaces(replace_break_line(element.text())); // simple case if(element[0].hasAttribute(current_value_attr)){ const value = replace_spaces(replace_break_line(element.attr(current_value_attr))); if(element[0].hasAttribute(config_data_type_attr)){ switch(element.attr(config_data_type_attr)) { case "bool": return value === true || value === "true"; case "number": return Number(value); default: return value; } }else{ return value; } } // with data type else if(element[0].hasAttribute(config_data_type_attr)){ switch(element.attr(config_data_type_attr)) { case "bool": return element.is(":checked"); case "list": const new_value = []; element.find(":selected").each(function(index, value){ new_value.splice(index, 0, replace_spaces(replace_break_line(value.text))); }); return new_value; case "number": return Number(raw_data); default: return raw_data; } // without information }else{ return raw_data; } } function _save_eval_config(element, restart_after_save){ const full_config = $("#super-container"); const updated_config = {}; const update_url = element.attr(update_url_attr); // take all tabs into account get_tabs_config().each(function(){ $(this).find("."+config_element_class).each(function(){ const config_type = $(this).attr(config_type_attr); if(!(config_type in updated_config)){ updated_config[config_type] = {}; } const new_value = parse_new_value($(this)); const config_key = get_config_key($(this)); if(get_config_value_changed($(this), new_value, config_key)){ updated_config[config_type][config_key] = new_value; } }) }); updated_config["restart_after_save"] = restart_after_save; // send update send_and_interpret_bot_update(updated_config, update_url, full_config, handle_save_buttons_success_callback); } function handle_save_buttons(){ $("#save-config").click(function() { _save_eval_config($(this), false); }) $("#save-config-and-restart").click(function() { _save_eval_config($(this), true); }) } function get_config_key(elem){ return elem.attr(config_key_attr); } function get_config_value_changed(element, new_value, config_key) { let new_value_str = new_value.toString(); if(new_value instanceof Array && new_value.length > 0){ //need to format array to match python string representation of config var str_array = []; $.each(new_value, function(i, val) { str_array.push("'"+val+"'"); }); new_value_str = "[" + str_array.join(", ") + "]"; } return get_value_changed(new_value_str, element.attr(config_value_attr), config_key); } function get_value_changed(new_val, dom_conf_val, config_key){ const lower_case_val = new_val.toLowerCase(); if(new_val.toLowerCase() !== dom_conf_val.toLowerCase()){ return true; }else if (config_key in validated_updated_global_config){ return lower_case_val !== validated_updated_global_config[config_key].toString().toLowerCase(); }else{ return false; } } function handle_save_buttons_success_callback(updated_data, update_url, dom_root_element, msg, status){ update_dom(dom_root_element, msg); create_alert("success", "Configuration successfully updated", "Restart OctoBot for changes to be applied."); } function handle_evaluator_configuration_editor(){ $(".config-element").click(function(e){ if (isDefined($(e.target).attr(no_activation_click_attr))){ // do not trigger when click on items with no_activation_click_attr set return; } const element = $(this); if (element.hasClass(config_element_class)){ if (element[0].hasAttribute(config_type_attr) && element.attr(config_type_attr) === evaluator_config_type){ // build data update let new_value = parse_new_value(element); let current_value; try { current_value = element.attr(current_value_attr).toLowerCase(); } catch(e) { current_value = element.attr(current_value_attr); } // todo if (current_value === "true"){ new_value = "false"; }else if(current_value === "false"){ new_value = "true"; } // update current value element.attr(current_value_attr, new_value); //update dom update_element_temporary_look(element); } } }); } function reset_configuration_element(){ remove_exit_confirm_function(); location.reload(); } let validated_updated_global_config = {}; $(document).ready(function() { handle_reset_buttons(); handle_save_buttons(); handle_evaluator_configuration_editor(); register_exit_confirm_function(something_is_unsaved); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/extensions.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const showModalIfNecessary = () => { $(".modal").each((_, element) => { const jqueryelement = $(element); if (jqueryelement.data("show-by-default") == "True") { jqueryelement.modal(); } }) } const handlePaymentWaiter = async () => { const waiterModal = $("#waiting-for-owned-packages-to-install-modal"); if(waiterModal && waiterModal.data("show-by-default") == "True"){ const url = waiterModal.data("url"); let hasExtension = false; while (!hasExtension){ const has_open_source_package_resp = await async_send_and_interpret_bot_update(null, url, null) if(has_open_source_package_resp.has_open_source_package){ hasExtension = true document.location.href = window.location.href.replace("&loop=true", "").replace("?refresh_packages=true", ""); } else { await new Promise(r => setTimeout(r, 3000)); } } } } const registerTriggerCheckout = () => { $("button[data-role=\"open-package-purchase\"]").click(() => { $("#select-payment-method-modal").modal(); }) $("button[data-role=\"restart\"]").click(() => { $("#select-payment-method-modal").modal(); }) $("button[data-role=\"open-checkout\"]").click(async (event) => { const button = $(event.currentTarget); const checkoutButtons = $("button[data-role=\"open-checkout\"]"); const origin_val = button.text(); const paymentMethod = button.data("payment-method") const url = button.data("checkout-api-url") const data = { paymentMethod: paymentMethod, redirectUrl: `${window.location.href}?refresh_packages=true&loop=true` } let fetchedCheckoutUrl = null; try { checkoutButtons.addClass("disabled"); button.html(" Loading checkout"); const checkoutUrl = await async_send_and_interpret_bot_update(data, url, null) if(checkoutUrl.url === null){ create_alert("success", "User already owns this extension", ""); } else { fetchedCheckoutUrl = checkoutUrl.url; $("p[data-role=\"checkout-url-fallback-part\"]").removeClass("d-none"); const checkoutUrlFallbackLink = $("a[data-role=\"checkout-url-fallback\"]"); checkoutUrlFallbackLink.attr("href", fetchedCheckoutUrl); checkoutUrlFallbackLink.text(fetchedCheckoutUrl); document.location.href = fetchedCheckoutUrl; } } finally { if(fetchedCheckoutUrl === null){ checkoutButtons.removeClass("disabled"); } button.html(origin_val); } }) } showModalIfNecessary(); registerTriggerCheckout(); handlePaymentWaiter(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/logs.js ================================================ function handleLogsExporter(){ trigger_file_downloader_on_click($(".export-logs-button")); } $(document).ready(function() { $('#logs_datatable').DataTable({ // order by time: most recent first "order": [[ 0, "desc" ]] }); $('#notifications_datatable').DataTable({ // order by time: most recent first "order": [[ 0, "desc" ]] }); handleLogsExporter(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/market_status.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function get_in_backtesting_mode() { return $("#symbol_graph").attr("backtesting_mode") === "True"; } function init_update_handler(){ socket.on("candle_graph_update_data", function (data) { if(!cancel_next_update){ updating_graph = true; update_graph(graph.attr("exchange"), true, data.data); }else{ cancel_next_update = false; } }); socket.on('new_data', function (data) { if(!cancel_next_update) { updating_graph = true; update_graph(graph.attr("exchange"), true, data.data, false); } }); } function schedule_update(){ setTimeout(function () { socket.emit("candle_graph_update", update_details); }, price_graph_update_interval) } function update_graph(exchange, update=false, data=undefined, re_update=true, initialization=false){ const in_backtesting = get_in_backtesting_mode(); if(isDefined(update_details.time_frame) && isDefined(update_details.symbol) && isDefined(exchange)){ const formated_symbol = update_details.symbol.replace(new RegExp("/","g"), "|"); if(isDefined(data) && (formated_symbol !== data.symbol.replace(new RegExp("/","g"), "|") || update_details.exchange_id !== data.exchange_id)){ return; } if (initialization && !in_backtesting){ init_update_handler(); updating_graph = false; } const valid_exchange_name = exchange.split("[")[0]; get_symbol_price_graph("graph-symbol-price", update_details.exchange_id, valid_exchange_name, formated_symbol, update_details.time_frame, true, in_backtesting, !update, true, 0, data, schedule_update); if (update && re_update && !in_backtesting){ schedule_update(); } }else{ const loadingSelector = $("div[name='loadingSpinner']"); if (loadingSelector.length) { loadingSelector.addClass(hidden_class); } $("#graph-symbol-price").html("Impossible to display price graph, if this error keeps appearing, " + "go to back to Trading and re-display this page.") } } function change_time_frame(new_time_frame) { update_details.time_frame = new_time_frame; update_graph(graph.attr("exchange")); } const graph = $("#symbol_graph"); const timeFrameSelect = $("#time-frame-select"); const update_details = { exchange_id: graph.attr("exchange_id"), symbol: graph.attr("symbol"), time_frame: timeFrameSelect.val() }; let updating_graph = false; let cancel_next_update = false; const socket = get_websocket("/dashboard"); $(document).ready(function() { update_graph(graph.attr("exchange"), false, undefined, true, true); timeFrameSelect.on('change', function () { const new_val = this.value; cancel_next_update = true; if(updating_graph){ setTimeout(function () { change_time_frame(new_val) }, 50); }else{ change_time_frame(new_val); } }); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/navbar.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function trigger_trader_state(element) { let updated_config = {}; const update_url = element.attr(update_url_attr); const config_key = element.attr(config_key_attr); const config_type = element.attr(config_type_attr); const set_to_activated = element.attr(current_value_attr).toLowerCase() === "true"; if (config_key === "trader_enabled") { updated_config = { [config_type]: { "trader_enabled": set_to_activated, "trader-simulator_enabled": !set_to_activated, } } } else { updated_config = { [config_type]: { "trader_enabled": !set_to_activated, "trader-simulator_enabled": set_to_activated, } } } updated_config["restart_after_save"] = true; // send update function post_trading_state_update_success_callback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", "Trader switched" , ""); hideTradingStateModal() } function post_trading_state_update_error_callback(updated_data, update_url, dom_root_element, result, status, error){ create_alert("error", "Error when switching trader : "+result.responseText, ""); hideTradingStateModal() } send_and_interpret_bot_update(updated_config, update_url, null, post_trading_state_update_success_callback, post_trading_state_update_error_callback); } function displayTradingStateModal() { showModalIfAny($("#tradingSwitchModal")) } function hideTradingStateModal() { hideModalIfAny($("#tradingSwitchModal")) } $(document).ready(function() { $("#switchTradingState").click(function(){ displayTradingStateModal() }); $(".trading-mode-switch-button").click(function(){ trigger_trader_state($(this)) }); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/portfolio.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const createPortfolioChart = (element_id, title, update) => { const data = {}; const element = $(`#${element_id}`); const max_medium_screen_legend_items = 50; const max_mobile_legend_items = 6; let at_least_one_value = false; let displayLegend = true; let graphHeight = element.attr("data-md-height"); if(isMobileDisplay()){ graphHeight = element.attr("data-sm-height"); } element.attr("height", graphHeight); $(".symbol-holding").each(function (){ const total_value = $(this).find(".total-value").text(); if($.isNumeric(total_value)){ data[$(this).find(".symbol").text()] = Number(total_value); if(Number(total_value) > 0 ){ at_least_one_value = true; } } }); const dataLength = Object.keys(data).length; // display graph only if at least one value is available if(at_least_one_value && dataLength > 0 && element.length > 0){ if(isMobileDisplay() && dataLength > max_mobile_legend_items){ // legend is hiding the chart on smaller displays if too many elements are present displayLegend = false; }else if(dataLength > max_medium_screen_legend_items){ // legend is hiding the chart on if too many elements are present displayLegend = false; } create_doughnut_chart(element[0], data, title, displayLegend, graphHeight, update); }else{ element.addClass(hidden_class); } } const handle_portfolio_button = () => { const refreshButton = $("#refresh-portfolio"); if(refreshButton){ refreshButton.click(function () { const update_url = refreshButton.attr(update_url_attr); send_and_interpret_bot_update({}, update_url, null, generic_request_success_callback, generic_request_failure_callback); }); } } const start_periodic_refresh = () => { setInterval(function() { $("#portfolio-display").load(location.href + " #portfolio-display", function (){ update_display(true, true); }); }, portfolio_update_interval); } const displayPortfolioTable = () => { handle_rounded_numbers_display(); ordersDataTable = $('#holdings-table').DataTable({ "paging": false, "bDestroy": true, "order": [[ 2, "desc" ]], "searching": $("tr.symbol-holding").length > 10, }); } const displayPortfolioContent = (referenceMarket, update) => { displayPortfolioTable(); const chartTitle = `Assets value (${referenceMarket})`; createPortfolioChart("portfolio_doughnutChart", chartTitle, update); handleButtons(); } const update_display = (withImages, update) => { const referenceMarket = $("#portfoliosCard").attr("reference_market"); displayPortfolioContent(referenceMarket, update); if(withImages){ handleDefaultImages(); } } let firstLoad = true; const handleClearButton = () => { $("#clear-portfolio-history-button").on("click", (event) => { if (confirm("Clear portfolio history ?") === false) { return false; } const url = $(event.currentTarget).data("url") const success = (updated_data, update_url, dom_root_element, msg, status) => { // reload page on success location.reload(); } send_and_interpret_bot_update(null, url, null, success, generic_request_failure_callback) }) } const handleButtons = () => { handle_portfolio_button(); handleClearButton() } update_display(false, false); if(firstLoad){ start_periodic_refresh(); } }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/profile_management.js ================================================ function handleProfileActivator(){ const profileActivatorButton = $(".activate-profile-button"); if (profileActivatorButton.length){ profileActivatorButton.click(function (){ const changeProfileURL = $(this).attr("data-url"); window.location.replace(changeProfileURL); }); } } function onProfileEdit(isEditing, profileSave){ // disable global save config button to avoid save buttons confusion $("#save-config").attr("disabled", isEditing); profileSave.attr("disabled", !isEditing); } function handleProfileEditor(){ const saveProfile = $(".save-profile"); const profileName = $('.profile-name-editor'); const profileDescription = $('.profile-description-editor'); const profileComplexity = $('.profile-complexity-selector'); const profileRisk = $('.profile-risk-selector'); profileName.on('save', function (){ onProfileEdit(true, $(this).parents(".profile-details").find(".save-profile")); }); profileDescription.on('save', function (){ onProfileEdit(true, $(this).parents(".profile-details").find(".save-profile")); }); profileComplexity.on('change', function (){ onProfileEdit(true, $(this).parents(".profile-details").find(".save-profile")); }); profileRisk.on('change', function (){ onProfileEdit(true, $(this).parents(".profile-details").find(".save-profile")); }); saveProfile.click(function (){ onProfileEdit(false, $(this)); $(this).tooltip("hide"); const updateURL = $(this).attr("data-url"); const profileDetails = $(this).parents(".profile-details"); const data = { id: profileDetails.attr("data-id"), name: profileDetails.find(".profile-name-editor").editable("getValue", true), description: profileDetails.find(".profile-description-editor").editable("getValue", true), complexity: profileDetails.find(".profile-complexity-selector").val(), risk: profileDetails.find(".profile-risk-selector").val(), }; send_and_interpret_bot_update(data, updateURL, null, saveCurrentProfileSuccessCallback, saveCurrentProfileFailureCallback); }); } function saveCurrentProfileSuccessCallback(updated_data, update_url, dom_root_element, msg, status){ create_alert("success", "Profile updated"); $("[data-role=profile-name]").each(function (){ const profileIdAttr = $(this).attr("data-profile-id"); if(typeof profileIdAttr === "undefined" || profileIdAttr === updated_data["id"]){ $(this).html(updated_data["name"]); } }); } function saveCurrentProfileFailureCallback(updated_data, update_url, dom_root_element, msg, status) { $("#save-current-profile").attr("disabled", false); create_alert("error", msg.responseText, ""); } function handleProfileCreator(){ const createButton = $(".duplicate-profile"); if(createButton.length){ createButton.click(function (){ send_and_interpret_bot_update({}, $(this).attr("data-url"), null, profileActionSuccessCallback, profileActionFailureCallback); }); } } function profileActionSuccessCallback(updated_data, update_url, dom_root_element, msg, status){ location.reload(); } function profileActionFailureCallback(updated_data, update_url, dom_root_element, msg, status) { create_alert("error", msg.responseText, ""); } function handleProfileImporter(){ const importForm = $(".profile-import-form"); const importButton = $(".import-profile-button"); const profileInput = $(".profile-input"); if(importForm.length && importButton.length && profileInput.length){ importButton.click(function () { $(this).siblings(".profile-import-form").find(".profile-input").click(); }); profileInput.on("change", function () { $(this).parents(".profile-import-form").submit(); }); } } function handleProfileDownloader(){ const downloadForm = $(".profile-download-form"); const importButton = downloadForm.find('button[data-role="download-profile-button"]'); const profileInput = $("#inputProfileLink"); if(importButton.length && profileInput.length){ importButton.click(function () { if($("#inputProfileLink").val()){ $(this).parents(".profile-download-form").submit(); } }); } } function handleProfileExporter(){ trigger_file_downloader_on_click($(".export-profile-button")); } function selectCurrentProfile(profileNameDisplay){ $("#profilesSubmenu").collapse("show"); const profileId = profileNameDisplay.attr("data-profile-id"); activate_tab($(`#profile-${profileId}-tab`)); } function handleProfileSelector(){ const profileNameDisplay = $("a[data-role=current-profile-selector]"); profileNameDisplay.click(function (){ selectCurrentProfile(profileNameDisplay); }); $("[data-role=current-profile-selector]").click(function (){ selectCurrentProfile(profileNameDisplay); }); } function handleProfileRemover(){ const removeProfileButton = $(".remove-profile-button"); if(removeProfileButton.length){ removeProfileButton.click(function (){ if (confirm("Delete this profile ?")) { const data = {id: $(this).attr("data-profile-id")}; send_and_interpret_bot_update(data, $(this).attr("data-url"), null, profileActionSuccessCallback, profileActionFailureCallback); } }); } } $(document).ready(function() { handleProfileActivator(); handleProfileSelector(); handleProfileEditor(); handleProfileCreator(); handleProfileImporter(); handleProfileDownloader(); handleProfileExporter(); handleProfileRemover(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/profiles_selector.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { // for some reason this is not always working when leaving it to bootstrap const ensureModals = () => { $('button[data-toggle="modal"]').each((_, element) => { $(element).click((event) => { const events = jQuery._data(event.currentTarget, "events" ) // One event means bootstrap did not register this click event if(typeof events !== "undefined" && typeof events.click !== "undefined" && events.click.length === 1){ const element = $(event.currentTarget); element.parent().children(element.data("target")).modal(); } }) }) } ensureModals(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/strategy_optimizer.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function recompute_nb_iterations(){ const nb_eval_iter = Math.pow(2, $("#evaluatorsSelect").find(":selected").length)-1; const nb_tf_iter = Math.pow(2, $("#timeFramesSelect").find(":selected").length)-1; const nb_selected = $("#risksSelect").find(":selected").length*nb_eval_iter*nb_tf_iter; $("#numberOfSimulatons").text(nb_selected); } function check_disabled(lock=false){ if(lock){ $("#startOptimizer").prop('disabled', true); } else if($("#strategySelect").find(":selected").length > 0 && $("#risksSelect").find(":selected").length > 0 && $("#timeFramesSelect").find(":selected").length > 0 && $("#evaluatorsSelect").find(":selected").length > 0){ $("#startOptimizer").prop('disabled', false); }else{ $("#startOptimizer").prop('disabled', true); } } function start_optimizer(source){ $("#progess_bar").show(); $("#progess_bar_anim").css('width', '0%').attr("aria-valuenow", '0'); source.prop('disabled', true); const update_url = source.attr(update_url_attr); const data = {}; data["strategy"]=get_selected_options($("#strategySelect")); data["time_frames"]=get_selected_options($("#timeFramesSelect")); data["evaluators"]=get_selected_options($("#evaluatorsSelect")); data["risks"]=get_selected_options($("#risksSelect")); send_and_interpret_bot_update(data, update_url, source, start_optimizer_success_callback, start_optimizer_error_callback); } function lock_inputs(lock=true){ const disabled_attr = 'disabled'; if ( $("#strategySelect").prop(disabled_attr) !== lock){ $("#strategySelect").prop(disabled_attr, lock); } if ( $("#timeFramesSelect").prop(disabled_attr) !== lock){ $("#timeFramesSelect").prop(disabled_attr, lock); } if ( $("#evaluatorsSelect").prop(disabled_attr) !== lock){ $("#evaluatorsSelect").prop(disabled_attr, lock); } if ( $("#risksSelect").prop(disabled_attr) !== lock){ $("#risksSelect").prop(disabled_attr, lock); } if(!($("#progess_bar").is(":visible")) && lock){ $("#progess_bar_anim").css('width', '0%').attr("aria-valuenow", '0'); $("#progess_bar").show(); } else if (!lock){ $("#progess_bar_anim").css('width', '100%').attr("aria-valuenow", '100'); $("#progess_bar").hide(); } check_disabled(lock); } function start_optimizer_success_callback(data, update_url, source, msg, status){ create_alert("success", msg, ""); lock_inputs(); } function start_optimizer_error_callback(data, update_url, source, result, status, error){ source.prop('disabled', false); $("#progess_bar").hide(); create_alert("error", "Error when starting optimizer: "+result.responseText, ""); } function populate_select(element, options){ element.empty(); // remove old options $.each(options, function(key, value) { if (key === 0){ element.append($('').attr("value", value).text(value)); }else{ element.append($('').attr("value", value).text(value)); } }); } function update_strategy_params(url, strategy){ var data = {strategy_name: strategy}; $.get(url, data, function(data, status){ populate_select($("#evaluatorsSelect"), data["evaluators"]); populate_select($("#timeFramesSelect"), data["time_frames"]); }); } function updateOptimizerProgress(progress, overall_progress){ $("#progess_bar_anim").css('width', progress+'%').attr("aria-valuenow", progress); const nb_progress = Number(overall_progress); if(isDefined(progressChart)){ update_circular_progress_doughnut(progressChart, nb_progress, 100 - nb_progress); $("#optimize_doughnutChart_progress").html(nb_progress.toString()+"%"); } } function check_optimizer_state(socket){ socket.emit("strategy_optimizer_status"); } function handle_optimizer_state_update(data){ const status = data["status"]; const progress = data["progress"]; const overall_progress = data["overall_progress"]; const errors = data["errors"]; const error_div = $("#error_info"); const error_text_div = $("#error_info_text"); const report_datatable_card = $("#report_datatable_card"); const has_errors = errors !== null; let alert_type = "success"; let alert_additional_text = "Strategy optimized finished simulations."; if(has_errors){ error_text_div.text(errors); error_div.show(); alert_type = "error"; alert_additional_text = "Strategy optimized finished simulations with error(s)." }else{ error_text_div.text(""); error_div.hide(); } if(status === "computing"){ lock_inputs(); updateOptimizerProgress(progress, overall_progress); first_refresh_state = status; if(report_datatable_card.is(":visible")){ report_datatable_card.hide(); reportTable.clear(); } } else{ lock_inputs(false); if(status === "finished"){ if(!report_datatable_card.is(":visible")){ report_datatable_card.show(); } if(reportTable.rows().count() === 0){ reportTable.ajax.reload( null, false); } if((first_refresh_state !== "" || has_errors) && first_refresh_state !== "finished"){ create_alert(alert_type, alert_additional_text, ""); first_refresh_state="finished"; } } } if(first_refresh_state === ""){ first_refresh_state = status; } } const iterationColumnsDef = [ { "title": "#", "targets": 0, "data": "id", "name": "id", "render": function(data, type, row, meta){ return data; } }, { "title": "Evaluator(s)", "targets": 1, "data": "evaluators", "name": "evaluators", "render": function(data, type, row, meta){ return data; } }, { "title": "Time Frame(s)", "targets": 2, "data": "time_frames", "name": "time_frames", "render": function(data, type, row, meta){ return data; } }, { "title": "Risk", "targets": 3, "data": "risk", "name": "risk", "render": function(data, type, row, meta){ return data; } }, { "title": "Average trades count", "targets": 4, "data": "average_trades", "name": "average_trades", "render": function(data, type, row, meta){ return data; } }, { "title": "Score: the higher the better", "targets": 5, "data": "score", "name": "score", "render": function(data, type, row, meta){ return data; } } ]; const reportColumnsDef = [ { "title": "#", "targets": 0, "data": "id", "name": "id", "render": function(data, type, row, meta){ return data; } }, { "title": "Evaluator(s)", "targets": 1, "data": "evaluators", "name": "evaluators", "render": function(data, type, row, meta){ return data; } }, { "title": "Risk", "targets": 2, "data": "risk", "name": "risk", "render": function(data, type, row, meta){ return data; } }, { "title": "Average trades count", "targets": 3, "data": "average_trades", "name": "average_trades", "render": function(data, type, row, meta){ return data; } }, { "title": "Comparative score: the lower the better", "targets": 4, "data": "score", "name": "score", "render": function(data, type, row, meta){ return data; } } ]; let first_refresh_state = ""; const progressChart = create_circular_progress_doughnut($("#optimize_doughnutChart")[0]); function init_websocket(){ const socket = get_websocket("/strategy_optimizer"); socket.on("strategy_optimizer_status", function (data) { handle_optimizer_state_update(data); }); return socket; } function init_data_tables_and_refreshers(){ reportTable = $("#report_datatable").DataTable({ ajax: { "url": $("#report_datatable").attr(update_url_attr), "dataSrc": "" }, deferRender: true, autoWidth: true, autoFill: true, columnDefs: reportColumnsDef }); const iterationTable = $("#results_datatable").DataTable({ ajax: { "url": $("#results_datatable").attr(update_url_attr), "dataSrc": "" }, deferRender: true, autoWidth: true, autoFill: true, columnDefs: iterationColumnsDef }); const socket = init_websocket(); setInterval(function(){refresh_message_table(iterationTable);}, 1500); function refresh_message_table(iterationTable){ iterationTable.ajax.reload( null, false ); if(iterationTable.rows().count() > 0){ $("#results_datatable_card").show(); } check_optimizer_state(socket); } } function register_events(){ $('#strategySelect').on('input', function() { update_strategy_params($('#strategySelect').attr(update_url_attr), $('#strategySelect').val()); }); $(".multi-select-element").select2({ dropdownAutoWidth : true, multiple: true, closeOnSelect: false }); $(".multi-select-element").on('change', function (e) { recompute_nb_iterations(); check_disabled(); }); $("#startOptimizer").click(function(){ start_optimizer($(this)); }); } let reportTable = undefined; $(document).ready(function() { check_disabled(); register_events(); init_data_tables_and_refreshers(); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/tentacles_configuration.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ function register_and_install_package(){ disable_packages_operations(); $("#register_and_install_package_progess_bar").show(); const element = $("#register_and_install_package_input"); const input_text = element.val(); const request = {}; request[$.trim(input_text)] = "register_and_install"; const full_config_root = element.parents("."+config_root_class); const update_url = full_config_root.attr(update_url_attr); send_and_interpret_bot_update(request, update_url, full_config_root, post_package_action_success_callback, post_package_action_error_callback) } function disable_packages_operations(should_lock=true){ const disabled_attr = 'disabled'; $("#install_tentacles_packages, #update_tentacles_packages, #install-beta-tentacles, #install-regular-tentacles").prop(disabled_attr, should_lock); $("#reset_tentacles_packages").prop(disabled_attr, should_lock); const register_and_install_package_input = $("#register_and_install_package_input"); register_and_install_package_input.prop(disabled_attr, should_lock); if(register_and_install_package_input.val() !== ""){ $("#register_and_install_package_button").prop(disabled_attr, should_lock); } const should_disable_buttons = get_selected_modules() <= 0; $('#uninstall_selected_tentacles').prop(disabled_attr, should_disable_buttons); $('#update_selected_tentacles').prop(disabled_attr, should_disable_buttons); } function update(module){ perform_modules_operation([module], "update"); } function uninstall(module){ if(confirm("Uninstall this tentacle ? This will delete the associated tentacle file if any.")) { perform_modules_operation([module], "uninstall"); } } function perform_modules_operation(modules, operation){ const dom_root_element = $("#module-table"); const update_url = dom_root_element.attr(operation+"-"+update_url_attr); disable_packages_operations(); send_and_interpret_bot_update(modules, update_url, dom_root_element, modules_operation_success_callback, modules_operation_error_callback) } function perform_packages_operation(source){ $("#packages_action_progess_bar").show(); const update_url = source.attr(update_url_attr); disable_packages_operations(); send_and_interpret_bot_update({}, update_url, source, packages_operation_success_callback, packages_operation_error_callback) } function modules_operation_success_callback(updated_data, update_url, dom_root_element, msg, status){ disable_packages_operations(false); $("#table-span").load(location.href + " #table-span",function(){ disable_select_action_buttons(); $('#tentacles_modules_table').DataTable({ "paging": false, }); }); $("#selected_tentacles_operation").hide(); create_alert("success", "Tentacle operation success", msg); } function modules_operation_error_callback(updated_data, update_url, dom_root_element, result, status, error){ disable_packages_operations(false); $("#table-span").load(location.href + " #table-span",function(){ disable_select_action_buttons(); $('#tentacles_modules_table').DataTable({ "paging": false, }); }); $("#selected_tentacles_operation").hide(); create_alert("error", "Error when managing modules: "+result.responseText, ""); } function packages_operation_success_callback(updated_data, update_url, dom_root_element, msg, status){ disable_packages_operations(false); $("#tentacles_modules_table").load(location.href + " #tentacles_modules_table",function(){ disable_select_action_buttons(); }); $("#packages_action_progess_bar").hide(); create_alert("success", "Packages operation success", msg); } function packages_operation_error_callback(updated_data, update_url, dom_root_element, result, status, error){ disable_packages_operations(false); $("#tentacles_modules_table").load(location.href + " #tentacles_modules_table",function(){ disable_select_action_buttons(); }); $("#packages_action_progess_bar").hide(); create_alert("error", "Error when managing packages: "+result.responseText, ""); } function post_package_action_success_callback(updated_data, update_url, dom_root_element, msg, status){ let package_path; for(const attribute in updated_data) { package_path = attribute; } create_alert("success", "Tentacles successfully installed" , "Packages installed from: "+package_path); $("#tentacles_packages_table").load(location.href + " #tentacles_packages_table"); $("#tentacles_modules_table").load(location.href + " #tentacles_modules_table",function(){ disable_select_action_buttons(); }); $("#register_and_install_package_progess_bar").hide(); disable_packages_operations(false); } function post_package_action_error_callback(updated_data, update_url, dom_root_element, result, status, error){ create_alert("error", "Error during package handling: "+result.responseText, ""); $("#tentacles_packages_table").load(location.href + " #tentacles_packages_table"); $("#tentacles_modules_table").load(location.href + " #tentacles_modules_table",function(){ disable_select_action_buttons(); }); $("#register_and_install_package_progess_bar").hide(); disable_packages_operations(false); } function get_selected_modules(){ const selected_modules = []; $("#module-table").find("input[type='checkbox']:checked").each(function(){ selected_modules.push($(this).attr("module")); }); return selected_modules } function handle_tentacles_buttons(){ $("#install_tentacles_packages, #update_tentacles_packages, #install-beta-tentacles, #install-regular-tentacles").click(function(){ perform_packages_operation($(this)); }); $("#reset_tentacles_packages").click(function(){ if(confirm("Reset all installed tentacles ? " + "WARNING: you will have to re-install the default tentacles and restart your OctoBot to continue " + "using this interface (this interface is an OctoBot tentacle). " + "This will delete all tentacle files but will save your tentacles configuration.")) { perform_packages_operation($(this)); } }); $("#uninstall_selected_tentacles").click(function(){ const selected_modules = get_selected_modules(); if(selected_modules.length > 0){ if(confirm("Uninstall these tentacles ? This will delete all the associated tentacle files if any.")) { $("#selected_tentacles_operation").show(); disable_packages_operations(); perform_modules_operation(selected_modules,"uninstall"); } } }); $("#update_selected_tentacles").click(function(){ const selected_modules = get_selected_modules(); if(selected_modules.length > 0){ $("#selected_tentacles_operation").show(); disable_packages_operations(); perform_modules_operation(selected_modules,"update"); } }); } function disable_select_action_buttons(){ $('#update_selected_tentacles').prop('disabled', true); $('#uninstall_selected_tentacles').prop('disabled', true); $('.selectable_tentacle').click(function () { // use parent not to trigger selection on button column use const row = $(this).parent(); if (row.hasClass(selected_item_class)){ row.removeClass(selected_item_class); row.find(".tentacle-module-checkbox").prop('checked', false); }else{ row.toggleClass(selected_item_class); row.find(".tentacle-module-checkbox").prop('checked', true); } const should_disable_buttons = get_selected_modules() <= 0; $('#uninstall_selected_tentacles').prop('disabled', should_disable_buttons); $('#update_selected_tentacles').prop('disabled', should_disable_buttons); }); } $(document).ready(function() { handle_tentacles_buttons(); $('#register_and_install_package_button').prop('disabled', true); $('#register_and_install_package_input').keyup(function() { $('#register_and_install_package_button').prop('disabled', $(this).val() === ''); }); disable_select_action_buttons(); $('#tentacles_modules_table').DataTable({ "paging": false, }); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/trading.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(async () => { const addOrRemoveWatchedSymbol = (event) => { const sourceElement = $(event.target); const symbol = sourceElement.attr("symbol"); let action = "add"; if(sourceElement.hasClass("fas")){ action = "remove"; } const request = {}; request["action"]=action; request["symbol"]=symbol; const update_url = sourceElement.attr("update_url"); send_and_interpret_bot_update(request, update_url, sourceElement, watched_symbols_success_callback, watched_symbols_error_callback) } const watched_symbols_success_callback = (updated_data, update_url, dom_root_element, msg, status) => { create_alert("success", msg, ""); if(updated_data["action"] === "add"){ dom_root_element.removeClass("far"); dom_root_element.addClass("fas"); }else{ dom_root_element.removeClass("fas"); dom_root_element.addClass("far"); } } const watched_symbols_error_callback = (updated_data, update_url, dom_root_element, result, status, error) => { create_alert("error", result.responseText, ""); } const update_pairs_colors = () => { $(".pair_status_card").each((_, jselement) => { const element = $(jselement); const first_eval = element.find(".status"); const status = first_eval.attr("status"); if(status.toLowerCase().includes("very long")){ element.addClass("card-very-long"); }else if(status.toLowerCase().includes("long")){ element.addClass("card-long"); }else if(status.toLowerCase().includes("very short")){ element.addClass("card-very-short"); }else if(status.toLowerCase().includes("short")){ element.addClass("card-short"); } }) } const get_displayed_orders_desc = () => { const orderDescs = []; const cancelButtonIndex = 8; $("#orders-table").DataTable().rows({filter: 'applied'}).data().map((value) => { orderDescs.push(value[cancelButtonIndex]); }); return orderDescs; } const handleClearButton = () => { $("#clear-trades-history-button").on("click", (event) => { if (confirm("Clear trades history ?") === false) { return false; } const url = $(event.currentTarget).data("url") const success = (updated_data, update_url, dom_root_element, msg, status) => { // reload page on success reload_trades(true); reload_pnl(true); } send_and_interpret_bot_update(null, url, null, success, generic_request_failure_callback) }) } const handle_cancel_buttons = () => { $("#cancel_all_orders").click((e) => { const to_cancel_orders = get_displayed_orders_desc(); $("#ordersCount").text(to_cancel_orders.length); cancel_after_confirm($('#CancelAllOrdersModal'), to_cancel_orders, $(e.currentTarget).attr(update_url_attr), true); }); } const handle_close_buttons = () => { $("button[data-action=close_position]").each((_, jsElement) => { $(jsElement).click((e) => { const element = $(e.currentTarget); close_after_confirm($('#ClosePositionModal'), element.data("position_symbol"), element.data("position_side"), element.data("update-url")); }); }); } const cancel_after_confirm = (modalElement, data, update_url, disable_cancel_buttons=false) => { modalElement.modal("toggle"); const confirmButton = modalElement.find(".btn-danger"); confirmButton.off("click"); modalElement.keypress((e) => { if(e.which === 13) { handle_confirm(modalElement, confirmButton, data, update_url, disable_cancel_buttons); } }); confirmButton.click(() => { handle_confirm(modalElement, confirmButton, data, update_url, disable_cancel_buttons); }); } const close_after_confirm = (modalElement, symbol, side, update_url) => { modalElement.modal("toggle"); const confirmButton = modalElement.find(".btn-danger"); confirmButton.off("click"); const data = { symbol: symbol, side: side, } modalElement.keypress((e) => { if(e.which === 13) { handle_close_confirm(modalElement, confirmButton, data, update_url); } }); confirmButton.click(() => { handle_close_confirm(modalElement, confirmButton, data, update_url); }); } const handle_close_confirm = (modalElement, confirmButton, data, update_url) => { send_and_interpret_bot_update(data, update_url, null, orders_request_success_callback, position_request_failure_callback); modalElement.unbind("keypress"); modalElement.modal("hide"); } const handle_confirm = (modalElement, confirmButton, data, update_url, disable_cancel_buttons) => { if (disable_cancel_buttons){ disable_cancel_all_buttons(); } send_and_interpret_bot_update(data, update_url, null, orders_request_success_callback, orders_request_failure_callback); modalElement.unbind("keypress"); modalElement.modal("hide"); } const add_cancel_individual_orders_buttons = () => { $("button[action=cancel_order]").each((_, element) => { $(element).off("click"); $(element).on("click", (event) => { cancel_after_confirm($('#CancelOrderModal'), $(event.currentTarget).attr("order_desc"), $(event.currentTarget).attr(update_url_attr)); }); }); } const disable_cancel_all_buttons = () => { $("#cancel_all_orders").prop("disabled",true); $("#cancel_order_progress_bar").show(); const cancelIcon = $("#cancel_all_icon"); cancelIcon.removeClass("fas fa-ban"); cancelIcon.addClass("fa fa-spinner fa-spin"); $("button[action=cancel_order]").each((_, jsElement) => { $(jsElement).prop("disabled",true); }); } const orders_request_success_callback = (updated_data, update_url, dom_root_element, msg, status) => { if(msg.hasOwnProperty("title")){ create_alert("success", msg["title"], msg["details"]); }else{ create_alert("success", msg, ""); } debouncedReloadDisplay(); } const orders_request_failure_callback = (updated_data, update_url, dom_root_element, msg, status) => { create_alert("error", msg.responseText, ""); debouncedReloadDisplay(); } const position_request_failure_callback = (updated_data, update_url, dom_root_element, msg, status) => { create_alert("error", msg.responseText, ""); } const async_get_data_from_url = async (element) => { const url = element.data("url"); if(typeof url === "undefined"){ return []; } return await async_send_and_interpret_bot_update(null, url, null, "GET", true) } const reload_positions = async (update) => { const table = $("#positions-table"); const closePositionUrl = table.data("close-url"); const positions = await async_get_data_from_url(table) $("#positions-waiter").hide(); displayPositionsTable("positions-table", positions, closePositionUrl, update); } const reload_trades = async (update) => { const table = $("#trades-table"); const refMarket = table.data("reference-market"); const trades = await async_get_data_from_url(table) $("#trades-waiter").hide(); return displayTradesTable("trades-table", trades, refMarket, update); } const reload_orders = async (update) => { const table = $("#orders-table"); const cancelOrderUrl = table.data("cancel-url"); const orders = await async_get_data_from_url(table) $("#orders-waiter").hide(); displayOrdersTable("orders-table", orders, cancelOrderUrl, update); } const registerScaleSelector = () => { $('a[data-action="change-scale"]').on("click", (event) => { const selector = $(event.currentTarget); if(!selector.hasClass("active")){ selector.addClass("active"); $('a[data-action="change-scale"]').each((_, jselement) => { const element = $(jselement); if(element.data("scale") !== selector.data("scale")){ element.removeClass("active"); } }) reload_pnl(true); } }) } const registerSymbolSelector = () => { $("#symbol-select").on("change", () => { reload_pnl(true); }) } const registerFilterSelectors = () => { registerScaleSelector(); registerSymbolSelector(); } const getScale = () => { return $('a.nav-link.scale-selector.active').data("scale"); } const getSymbol = () => { return $("#symbol-select").val(); } const updateTradesCount = (pnlHistory) => { $("#match-trades-count").text(pnlHistory.reduce((sum, element) => sum + element.tc, 0)); } const reload_pnl = async (update) => { if ($("#pnl_historyChart").length){ const pnlHistory = await fetchPnlHistory(getScale(), getSymbol()); loadPnlFullChartHistory(pnlHistory, update); loadPnlTableHistory(pnlHistory, update); updateTradesCount(pnlHistory); $("#pnl-waiter").hide(); } } const resizePnlChart = () => { Plotly.Plots.resize("pnl_historyChart") } const ordersNotificationCallback = (title, _) => { if(title.toLowerCase().indexOf("order") !== -1){ debouncedReloadDisplay(); } } function debouncedReloadDisplay(){ debounce( () => reloadDisplay(true), 500 ); } const reloadDisplay = async (update) => { if(!update){ await reload_pnl(update); } await reload_orders(update); await reload_positions(update); if(await reload_trades(update) && update){ // only update pnl when a new trade appeared await reload_pnl(update); } } const onPnlTabShow = (e) => { resizePnlChart(); } const registerOnTabShownEvents = () => { $("#panel-pnl-tab").on("shown.bs.tab", (e) => onPnlTabShow(e)); } const registerTableButtonsEvents = () => { $("#positions-table").on("draw.dt", () => { handle_close_buttons(); }); $("#orders-table").on("draw.dt row-reordered", () => { if($("#orders-table").data("cancel-url") === undefined){ return } add_cancel_individual_orders_buttons(); const cancelIcon = $("#cancel_all_icon"); $("#cancel_order_progress_bar").hide(); if(cancelIcon.hasClass("fa-spinner")){ cancelIcon.removeClass("fa fa-spinner fa-spin"); cancelIcon.addClass("fas fa-ban"); } if ($("button[action=cancel_order]").length === 0){ $("#cancel_all_orders").prop("disabled", true); }else{ $("#cancel_all_orders").prop("disabled", false); } }); } const refreshTables = async () => { if (!initializedTables){ await reloadDisplay(true); initializedTables = true } } let initializedTables = false selectFirstTab(); registerFilterSelectors(); registerTableButtonsEvents(); update_pairs_colors(); $(".watched_element").each((_, element) => { $(element).click(addOrRemoveWatchedSymbol); }); handle_cancel_buttons(); handleClearButton(); register_notification_callback(ordersNotificationCallback); await reloadDisplay(false); registerOnTabShownEvents(); handle_rounded_numbers_display(); try { if(registerGraphUpdateCallback !== undefined){ registerGraphUpdateCallback(refreshTables); } } catch (error){ // nothing to do, registerGraphUpdateCallback doesn't exist } }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/trading_type_selector.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { let portfolioEditor = null; let allEditables = []; let updateRequired = false; let initialPortfolioValue = null; let initiallySelectedExchange; const getSelectedExchange = () => { return $("#AddExchangeSelect").val(); } const displayPortfolioEditor = (currencies) => { const editorDiv = $("#portfolio-editor"); let value = editorDiv.data("portfolio"); if(initialPortfolioValue === null){ initialPortfolioValue = JSON.parse(JSON.stringify(value)); // deep copy initial value } if(typeof value === "undefined"){ return } const schema = editorDiv.data("portfolio-schema"); if(portfolioEditor !== null) { value = portfolioEditor.getValue(); portfolioEditor.destroy(); } value.forEach((val) => { if(currencies.indexOf(val.asset) === -1){ currencies.push(val.asset) } }) schema.items.properties.asset.enum = currencies.sort(); portfolioEditor = new JSONEditor(editorDiv[0],{ schema: schema, startval: value, no_additional_properties: true, prompt_before_delete: false, disable_array_reorder: true, disable_array_delete: false, disable_array_delete_last_row: false, disable_collapse: true, disable_edit_json: true, disable_properties: true, }) } const updateCurrencySelector = () => { const editorDiv = $("#portfolio-editor"); if(!editorDiv.length){ return; } const currencies_url = `${editorDiv.data("currencies-url")}${getSelectedExchange()}`; const successCallback = (updated_data, update_url, dom_root_element, msg, status) => { displayPortfolioEditor(msg) } send_and_interpret_bot_update({}, currencies_url, null, successCallback, generic_request_failure_callback, "GET"); } const updateSelectedExchange = () => { // update exchange api key form, logo & name const exchangeContent = $("#exchanges-tab-content"); const previousExchangeName = exchangeContent.data("exchange-name"); if(typeof previousExchangeName === "undefined"){ log("undefined previous exchange name when updating selected exchange"); return } // prevent editable to be stuck open hide_editables(allEditables); const newExchangeName = getSelectedExchange(); // update exchange name exchangeContent.data("exchange-name", newExchangeName); exchangeContent.find(`[url="/exchange_logo/${previousExchangeName}"]`).attr("src", "").addClass(hidden_class); const toUpdateElements = [ $("#simulated-config-header"), $("#exchange-container"), ]; toUpdateElements.forEach((element) => { element.html( element.html().replace(new RegExp(previousExchangeName,"g"), newExchangeName) ); }) // update logo fetch_images(); // trigger accounts check register_exchanges_checks(true); // update form handleEditables(); } const registerUpdatesOnExchangeSelect = () => { $("#AddExchangeSelect").on("change", () => { updateCurrencySelector(); updateSelectedExchange(); }) } const getConfigUpdate = (isRealTrading) => { const globalConfigUpdate = {} const removedElements = []; let simulatorEnabled = true; let realEnabled = false; const selectedExchange = getSelectedExchange(); const getConfigPortfolioAssetKey = (portfolioAsset) => { return `trader-simulator_starting-portfolio_${portfolioAsset.asset}`; } if(selectedExchange !== initiallySelectedExchange){ // update enabled exchange globalConfigUpdate[`exchanges_${initiallySelectedExchange}_enabled`] = false; globalConfigUpdate[`exchanges_${selectedExchange}_enabled`] = true; } if(isRealTrading){ const hasValueChanged = (editableElement) => { return editableElement.data("changed") === true; } // update exchange api keys allEditables.forEach((editableElement) => { if(hasValueChanged(editableElement)){ globalConfigUpdate[editableElement.attr("config-key")] = editableElement.text().trim(); } }) realEnabled = true; simulatorEnabled = false; }else{ // update simulated portfolio // trader-simulator_starting-portfolio_BTC const updatedPortfolio = portfolioEditor.getValue(); if(initialPortfolioValue !== null && getValueChangedFromRef(updatedPortfolio, initialPortfolioValue)) { const remainingElements = Array.from(updatedPortfolio, (element) => element.asset); initialPortfolioValue.forEach((portfolioAsset) => { if(remainingElements.indexOf(portfolioAsset.asset) === -1){ removedElements.push(getConfigPortfolioAssetKey(portfolioAsset)) } }); updatedPortfolio.forEach((portfolioAsset) => { globalConfigUpdate[getConfigPortfolioAssetKey(portfolioAsset)] = portfolioAsset.value; }); } } const hasRealTrader = $("#exchanges-tab-content").data("has-real-trader") === "True"; if((hasRealTrader && !realEnabled) || (!hasRealTrader && !simulatorEnabled)){ globalConfigUpdate["trader_enabled"] = realEnabled; globalConfigUpdate["trader-simulator_enabled"] = simulatorEnabled; } updateRequired = Object.keys(globalConfigUpdate).length + removedElements.length > 0; return { global_config: globalConfigUpdate, removed_elements: removedElements, } } const handleStartTradingButtons = () => { $("button[data-role=\"start-trading\"]").click((e) => { const startButton = $(e.currentTarget); const isRealTrading = startButton.data("trading-type") === "real"; const configUpdate = getConfigUpdate(isRealTrading); if(!isRealTrading){ const errorsDesc = validateJSONEditor(portfolioEditor); if (errorsDesc.length) { create_alert("error", "Error in portfolio configuration", errorsDesc); return; } } const rebootRequired = updateRequired || new URL(window.location.href).searchParams.get("reboot") === "True"; const updateUrl = startButton.data("config-url"); const startUrl = `${startButton.data("start-url")}${rebootRequired}`; const onSuccess = (updated_data, update_url, dom_root_element, msg, status) => { // redirect to start url window.location.href = startUrl; } if(updateRequired){ // update is required before reboot send_and_interpret_bot_update(configUpdate, updateUrl, null, onSuccess, generic_request_failure_callback); } else { // skip update onSuccess(null, null, null, null, null); } }); } const handleEditables = () => { setup_editable(); allEditables = handle_editable(); } initiallySelectedExchange = getSelectedExchange(); updateCurrencySelector(); displayPortfolioEditor([]); registerUpdatesOnExchangeSelect(); handleEditables(); handleStartTradingButtons(); register_exchanges_checks(true); }); ================================================ FILE: Services/Interfaces/web_interface/static/js/components/tradingview_email_config.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ const showVerifCodeError = (errorDetails) => { $("[data-role='verification-code-waiter']").addClass(hidden_class) $("[data-role='verification-code-received']").addClass(hidden_class) $("[data-role='verification-code-error']").removeClass(hidden_class) $("#verification-code-error-content").text(errorDetails) } const showVerifCodeWaiter = () => { $("[data-role='verification-code-waiter']").removeClass(hidden_class) $("[data-role='verification-code-received']").addClass(hidden_class) $("[data-role='verification-code-error']").addClass(hidden_class) } const showVerifCodeReceived = (confirmEmailContent) => { $("[data-role='verification-code-waiter']").addClass(hidden_class) $("[data-role='verification-code-received']").removeClass(hidden_class) $("#verification-code-received-content").text(confirmEmailContent) $("[data-role='verification-code-error']").addClass(hidden_class) } const triggerEmailConfirmWaiter = async () => { const stepperElement = $("#config-stepper"); try { await async_send_and_interpret_bot_update(null, stepperElement.data("trigger-verif-code-waiter"), null, "POST", true) let confirmEmailContent = null; const timeout = 5 * 60 * 1000; const t0 = new Date().getTime(); while (confirmEmailContent === null) { if (new Date().getTime() - t0 > timeout){ showVerifCodeError(""); } else { showVerifCodeWaiter(); } confirmEmailContent = await async_send_and_interpret_bot_update(null, stepperElement.data("get-verif-code-content"), null, "GET", true) await sleep(2000) } if (confirmEmailContent === null) { // error: email was not received within given time showVerifCodeError(); } else { showVerifCodeReceived(confirmEmailContent); } } catch(error) { showVerifCodeError(error.responseText) } } ================================================ FILE: Services/Interfaces/web_interface/static/js/components/wait_reboot.js ================================================ /* * Drakkar-Software OctoBot * Copyright (c) Drakkar-Software, All rights reserved. * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3.0 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library. */ $(document).ready(function() { const onReconnected = () => { const loader = $("#restart-loader"); if(loader.length){ // change current page when reconnected window.location.href = loader.data("redirect-url"); } } registerReconnectedCallback(onReconnected); }); ================================================ FILE: Services/Interfaces/web_interface/static/license.txt ================================================ Material Design for Bootstrap (MDB) under MIT license Copyright (c) 2017 MDBootstrap.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Services/Interfaces/web_interface/templates/404.html ================================================ {% extends "layout.html" %} {% block body %}

We are sorry, but this doesn't exist.

You may have mistyped the address or the page may have moved.
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/500.html ================================================ {% extends "layout.html" %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% block body %}

We are sorry, but an unexpected error occurred.

{{ m_flash_messages.flash_messages() }}

Error: {{ error }} ({{ error.__class__.__name__ }}). More details in logs.

If you believe this is an issue with OctoBot, please open an issue describing what happened on the OctoBot issues report system.

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/about.html ================================================ {% extends "layout.html" %} {% set active_page = "about" %} {% import 'macros/text.html' as m_text %} {% import "components/community/octobot_cloud_description.html" as octobot_cloud_description %} {% block body %}

Your OctoBot

{% if current_user.is_authenticated %} {% endif %}

Get more from OctoBot using OctoBot cloud

{{ octobot_cloud_description.octobot_cloud_description(OCTOBOT_COMMUNITY_URL, LOCALE) }}

Running your OctoBot on the cloud

While it is possible to use OctoBot directly from your computer as much as you want, you can also also easily host your OctoBot on the cloud using DigitalOcean.

OctoBot is available from on the DigitalOcean marketplace. It enables you to have your OctoBot executing your trading strategies 24/7 without having to leave a computer on.


Help us to improve OctoBot

Any question ? Please have a look at our Frequently ask question (FAQ) section first !
{% if not IS_CLOUD %}
metrics to help the OctoBot Community

This will greatly help the OctoBot team to figure out the best ways to improve OctoBot and won't slow your OctoBot down.

{% endif %}

In order to improve OctoBot, your user feedback is extremely helpful. The best way to make this project better and better is by telling us about your experience (positive and negative) when using OctoBot.

Tell us what you think about OctoBot Suggest a feature for OctoBot

Disclaimer

{{ m_text.text_lines(disclaimer) }}

Terms and conditions


OctoBot Beta Tester program

You can help the team improving OctoBot by testing features in advance through the beta tester group. Registering to the beta tester group will grant you access to major new features weeks in advance as well as a direct communication channel to the OctoBot team to share your feedback and ideas before new versions are released to the public. More info on the beta tester program

When the beta environment is enabled, you will be connected to the "in development" version of {{OCTOBOT_COMMUNITY_URL}}. Available elements will be different from normal ones and your OctoBot might produce unexpected behaviors. Only enable it when actively beta testing and disable it afterwards.

Register to the beta tester program


{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/accounts.html ================================================ {% extends "layout.html" %} {% set active_page = "accounts" %} {% set startup_messages_added_classes = "justify-content-end px-4" %} {% set inner_startup_messages_added_classes = "offset-md-3 offset-lg-2 offset-1" %} {% import 'components/config/exchange_card.html' as m_config_exchange_card %} {% import 'components/config/service_card.html' as m_config_service_card %} {% import 'components/config/notification_config.html' as m_config_notification %} {% set config_default_value = "Bitcoin" %} {% set added_class = "new_element" %} {% block additional_style %} {% endblock additional_style %} {% block body %}

Exchanges

Add a new exchange :

{% for exchange in config_exchanges %} {{ m_config_exchange_card.config_exchange_card(config_exchanges, exchange, exchanges_details[exchange], is_supporting_future_trading, enabled=config_exchanges[exchange].get('enabled', True), sandboxed=config_exchanges[exchange].get('sandboxed', False), selected_exchange_type=config_exchanges[exchange].get('exchange-type', 'spot'), full_config=True)}} {% endfor %}

Interfaces

Add a new interface :

{% for service in services_list %} {% if service in config_services %} {{ m_config_service_card.config_service_card(config_services, service, services_list[service], extension_name=OCTOBOT_EXTENSION_PACKAGE_1_NAME) }} {% endif %} {% endfor %}

Notifications

{{ m_config_notification.config_notification(config_notifications, "notification", notifiers_list) }}
{{ m_config_exchange_card.config_exchange_card( config=config_exchanges, exchange=config_default_value, exchanges_details=exchanges_details, is_supporting_future_trading=is_supporting_future_trading, add_class=added_class, keys_value="NO KEY", config_values="no value", full_config=True) }}
{% for service in services_list %}
{{ m_config_service_card.config_service_card( config_services, service, services_list[service], add_class=added_class, no_select=True, default_values=True, extension_name=OCTOBOT_EXTENSION_PACKAGE_1_NAME) }}
{% endfor %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/automations.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% macro automation_details(automations) %}
    {% for automation in automations %}
  • {{automation.get_name()}}: {{automation.get_description() }}
  • {% endfor %}
{% endmacro %} {% block body %}

Automations configuration of the {{ profile_name }} profile

Automations are actions that will be triggered automatically when something happens. You can have as many automations as you want. Automation are started automatically when your OctoBot starts and when hitting Apply.

Loading configuration


{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/backtesting.html ================================================ {% extends "layout.html" %} {% set active_page = "backtesting" %} {% import 'macros/backtesting_utils.html' as m_backtesting_utils %} {% block body %}

Backtesting {% if activated_trading_mode %} Current trading mode: {{ activated_trading_mode.get_name() }} {% endif %}

{% if data_files %} {% for file, description in data_files %} {% if description.start_timestamp %} {% else %} {% endif %} {% endfor %}
Select data file(s) to use
# Symbol(s) Date of recording Candles Exchange Time frame(s) File
{{", ".join(description.symbols)}} {{description.start_date}} to {{description.end_date}} Full{{description.date}} {{description.candles_length}}{{description.exchange}} {{", ".join(description.time_frames)}} {{file}}
{% if activated_trading_mode and activated_trading_mode.is_backtestable() %}
Start Date :
End Date :
{% elif activated_trading_mode %} {% elif not activated_trading_mode %} {% endif %} {% else %}

No backtesting data files found. Once you have data files, they will be displayed here to be used in backtesting.

{% endif %}

{{ m_backtesting_utils.backtesting_report('backtesting', OCTOBOT_DOCS_URL, has_open_source_package) }} {% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/community.html ================================================ {% extends "layout.html" %} {% set active_page = "community" %} {% import "components/community/bot_selector.html" as bot_selector %} {% import "components/community/cloud_strategies.html" as cloud_strategies_display %} {% import "components/community/bots_stats.html" as bots_stats %} {% import "components/community/tentacle_packages.html" as m_tentacle_packages %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% block body %}
{{ bots_stats.bots_stats_card(current_bots_stats) }} {{ m_flash_messages.flash_messages() }} {{ m_tentacle_packages.pending_tentacles_install_modal(has_owned_packages_to_install and not auto_refresh_packages) }} {{ cloud_strategies_display.cloud_strategies(strategies, OCTOBOT_COMMUNITY_URL, LOCALE, OCTOBOT_EXTENSION_PACKAGE_1_NAME, has_open_source_package) }}
Logged in as {{current_logged_in_email}} {% if selected_user_bot["name"] %} using bot {{ selected_user_bot["name"] }} {% else %} without selected bot {% endif %} {% if can_select_bot %} Select bot {% endif %} {% if 'tester' in role %} {% elif 'contributor' in role %} {% elif 'sponsor' in role %} {% endif %} {% if is_donor %} {% endif %}
{% if can_logout %} {% endif %}
{% if can_select_bot %} {% endif %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/community_login.html ================================================ {% extends "layout.html" %} {% set active_page = "community" %} {% import "components/community/bots_stats.html" as bots_stats %} {% import "components/community/login.html" as login %} {% block body %}
{{ bots_stats.bots_stats_card(current_bots_stats) }}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/community_metrics.html ================================================ {% extends "layout.html" %} {% set active_page = "community" %} {% import 'macros/tables.html' as m_tables %} {% macro top_table_card(items, item_name, table_name) -%}

{{ table_name }}

{% for item in items %} {{ m_tables.top_tr(item) }} {% endfor %}
# {{ item_name }} OctoBots
{%- endmacro %} {% macro col_elem_with_badge(title, badge, badge_color="unique-color-dark") -%}
{{ title }}
{{ badge }}
{%- endmacro %} {% block body %}
{% if can_get_metrics %}

OctoBot Community metrics Community chat


Active OctoBots

{{ col_elem_with_badge("Total:", community_metrics['total_count']) }} {{ col_elem_with_badge("This month:", community_metrics['this_month'], badge_color="unique-color") }} {{ col_elem_with_badge("Last 6 months:", community_metrics['last_six_month']) }}


{{ top_table_card(community_metrics['top_real_exchanges'], "Exchange", "Top community exchanges") }} {{ top_table_card(community_metrics['top_real_eval_config'], "Tentacle", "Top community tentacles") }} {{ top_table_card(community_metrics['top_real_pairs'], "Pair", "Top community traded pairs") }}
{{ top_table_card(community_metrics['top_simulated_exchanges'], "Exchange", "Top community exchanges") }} {{ top_table_card(community_metrics['top_simulated_eval_config'], "Tentacle", "Top community tentacles") }} {{ top_table_card(community_metrics['top_simulated_pairs'], "Pair", "Top community traded pairs") }}
{% else %}

To be part of the OctoBot community, please enable OctoBot community data.

{% endif %}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/community_register.html ================================================ {% extends "layout.html" %} {% set active_page = "community" %} {% import "components/community/bots_stats.html" as bots_stats %} {% import "components/community/login.html" as login %} {% block body %}
{{ bots_stats.bots_stats_card(current_bots_stats) }}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/bot_selector.html ================================================ {% macro bot_selector(all_user_bots, selected_user_bot) -%}

Multiple bots available on this account, please select the one to use on this OctoBot.

{% for bot in all_user_bots %}
{{ bot["name"] }}
{% if selected_user_bot["id"] == bot["id"] %} {% else %} {% endif %}
{% endfor %}
Bots allow you to identify and load data about a particular OctoBot through your Community account.
Never use the same bot on multiple OctoBots at the same time as it will prevent it from working properly.
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/bots_stats.html ================================================ {% macro bots_stats_card(stats) -%}
Welcome to the OctoBot community and its {{ stats["total_bots"] }} already installed OctoBots
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/cloud_strategies.html ================================================ {% import 'components/community/cloud_strategies_selector.html' as m_cloud_strategies_selector %} {% import 'components/community/octobot_cloud_description.html' as octobot_cloud_description %} {% import "components/community/octobot_cloud_features.html" as octobot_cloud_features %} {% macro cloud_strategies(strategies, OCTOBOT_COMMUNITY_URL, LOCALE, OCTOBOT_EXTENSION_PACKAGE_1_NAME, has_open_source_package) -%}

OctoBot cloud strategies

{{ octobot_cloud_features.octobot_cloud_features(OCTOBOT_COMMUNITY_URL, LOCALE, 'community') }}

Find more information the recent changes and future plans on our blog.

Explore the OctoBot cloud strategies on www.octobot.cloud/explore.

Do you want increase the capabilities your OctoBot while supporting the project? Check out the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}}.

View the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}}
{{ m_cloud_strategies_selector.cloud_strategies_selector(strategies, LOCALE, "") }}
{% if not has_open_source_package() %} {% endif %}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/cloud_strategies_selector.html ================================================ {% macro cloud_strategies_selector(strategies, locale, post_install_action) -%}
{% for strategy in strategies %} {% if strategy.results %} {% else %} {% endif %} {% endfor %}
Strategy Profitability Traded coins Exchange Risk level Type {{'Use' if post_install_action else 'Install'}}
Strategy illustration
{{strategy.get_name(locale) | capitalize}}
{{ (strategy.results.get_max_value()) | round(2) }}% over {{ strategy.results.get_max_unit() }} {{ strategy.attributes['coins'] | join(', ') }} {{ strategy.attributes['exchanges'] | join(', ') }} {{ strategy.get_risk().name | capitalize }} {% if strategy.category %} {% if strategy.category.get_url() %} {{ strategy.category.get_name(locale) }} {% else %} {{ strategy.category.get_name(locale) }} {% endif %} {% endif %}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/login.html ================================================ {% from "macros/forms.html" import render_field %} {% macro login_form(form, is_in_stating_community_env, after_login_url, recover_password_url, after_login_action=None, details=None) -%}

OctoBot community

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %} {% if current_logged_in_email %}
Logged in as {{ current_logged_in_email }}
Logout {% else %}
Login and access your {% if is_in_stating_community_env() %} Beta {% endif %} Octobot account.
{% if details %}

{{ details }}

{% endif %} {{ login_form_content(form, 'community_login', after_login_url, after_login_action, "Login") }} {% endif %}
{% if not current_logged_in_email %} {% endif %}
{%- endmacro %} {% macro register_form(form, is_in_stating_community_env, after_login_url, after_login_action=None, details=None) -%}

Join the OctoBot community

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %}
Create your {% if is_in_stating_community_env() %} Beta {% endif %} Octobot account.
{% if details %}

{{ details }}

{% endif %} {{ login_form_content(form, 'community_register', after_login_url, after_login_action, "Register") }}
{%- endmacro %} {%- macro login_form_content(form, submit_route, after_login_url, after_login_action, form_value) %}
{{ form.csrf_token }}
{{ render_field(form.email, autofocus=true, class="form-control mx-auto", placeholder="Email") }}
{{ render_field(form.password, autofocus=true, class="form-control mx-auto", placeholder="Password") }}
{{ render_field(form.remember_me, class="custom-control-input") }}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/octobot_cloud_description.html ================================================ {% import "components/community/octobot_cloud_features.html" as octobot_cloud_features %} {% macro octobot_cloud_description(OCTOBOT_COMMUNITY_URL, LOCALE) -%}
Profit from OctoBot strategies in a simpler way

While the OctoBot you are currently using is about creating and testing your own strategy, octobot.cloud, enables every crypto investors to enjoy trading strategies in the simplest way possible.
It also uses OctoBot under the hood and is perfect to diversify crypto investments or for people who don't want the technical aspect of a trading bot.

{{ octobot_cloud_features.octobot_cloud_features(OCTOBOT_COMMUNITY_URL, LOCALE, 'about') }}

Follow the OctoBot news on our blog.

{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/octobot_cloud_features.html ================================================ {% macro octobot_cloud_features(OCTOBOT_COMMUNITY_URL, LOCALE, source) -%}
With OctoBot cloud, you can:
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/tentacle_packages.html ================================================ {% macro pending_tentacles_install_modal(show_by_default) -%} {%- endmacro %} {% macro waiting_for_owned_packages_to_install_modal(show_by_default) -%} {%- endmacro %} {% macro select_payment_method_modal(name) -%} {%- endmacro %} {% macro get_package_button(name, is_authenticated, has_open_source_package) -%} {% if has_open_source_package() %} {% elif is_authenticated %} {% else %} Login to install the {{name}} {% endif %} {%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/community/user_details.html ================================================ {% macro user_details( USER_EMAIL, USER_SELECTED_BOT_ID, has_open_source_package, PROFILE_NAME, TRADING_MODE_NAME, EXCHANGE_NAMES, IS_REAL_TRADING) -%} {%- endmacro %} {%- macro posthog(IS_DEMO, IS_CLOUD, IS_ALLOWING_TRACKING, PH_TRACKING_ID) -%} {% if IS_DEMO or IS_CLOUD or IS_ALLOWING_TRACKING%} {% else %} {% endif %} {%- endmacro -%} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/currency_card.html ================================================ {% import 'components/config/editable_config.html' as m_editable_config %} {% macro config_currency_card(config_symbols, crypto_currency, symbol_list_by_type, full_symbol_list, get_currency_id, add_class='', no_select=False, additional_classes="", symbol="") -%}
{{crypto_currency}}
{{ crypto_currency }}

{{ m_editable_config.editable_key( config_symbols, crypto_currency, "crypto-currencies_" + crypto_currency, "global_config", config_symbols[crypto_currency]['pairs'] if crypto_currency in config_symbols and 'pair' in config_symbols[crypto_currency] else [], config_symbols[crypto_currency]['pairs'] if crypto_currency in config_symbols and 'pair' in config_symbols[crypto_currency] else [], symbol_list_by_type, no_select, identifier=crypto_currency, placeholder_str="Select trading pair(s)", dict_as_option_group=True) }}

{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/editable_config.html ================================================ {% macro editable_key(config, key, config_key, config_type, config_value, startup_config_value, suggestions="", no_select=False, number_step=0.01, force_title=False, tooltip=None, identifier="", placeholder_str="", allow_create_for=None, edit_key=False, dict_as_option_group=False) -%} {% if config[key]|default (config_value) is string %} {{ editable_key_string(config, key, config_key, config_type, config_value, startup_config_value, suggestions, placeholder_str) }} {% elif config[key]|default (config_value) | is_dict %} {{ editable_key_dict(config, key, config_key, config_type, config_value, startup_config_value, suggestions, no_select, number_step, force_title, tooltip, identifier, placeholder_str, allow_create_for, dict_as_option_group=dict_as_option_group) }} {% elif config[key]|default (config_value) | is_list %} {{ editable_key_list(config, key, config_key, config_type, config_value, startup_config_value, suggestions, no_select, force_title, identifier, placeholder_str, dict_as_option_group=dict_as_option_group) }} {% elif config[key]|default (config_value) | is_bool %} {{ editable_key_bool(config, key, config_key, config_type, config_value, startup_config_value, suggestions, tooltip) }} {% elif config[key]|default (config_value) is number %} {{ editable_key_number(config, key, config_key, config_type, config_value, startup_config_value, suggestions, number_step, edit_key) }} {% else %} {{ editable_key_string(config, key, config_key, config_type, config_value, startup_config_value, suggestions, placeholder_str) }} {% endif %} {%- endmacro %} {% macro editable_key_dict(config, key, config_key, config_type, config_value, startup_config_value, suggestions, no_select, number_step, force_title, tooltip, identifier, placeholder_str, allow_create_for, dict_as_option_group) -%} {{ key }} :
{% for new_key in config[key] %}  {{ editable_key( config[key], new_key, config_key + "_" + new_key, config_type, config_value[new_key], startup_config_value[new_key], suggestions, no_select, number_step, force_title, tooltip, identifier, placeholder_str, edit_key=(allow_create_for == key), dict_as_option_group=dict_as_option_group) }} {% if loop.last and allow_create_for == key %} {{ editable_key( config[key], "Empty", config_key + "_Empty", config_type, config_value[new_key], startup_config_value[new_key], suggestions, no_select, number_step, force_title, tooltip, identifier, placeholder_str, edit_key=True, dict_as_option_group=dict_as_option_group) }}
{% endif %} {% endfor %} {%- endmacro %} {% macro editable_key_number(config, key, config_key, config_type, config_value, startup_config_value, suggestions, number_step, edit_key) -%} {% if edit_key %} {{ key }} {% else %} {{ key }} {% endif %}: {{ config[key]|default (config_value) }}
{%- endmacro %} {% macro editable_key_string(config, key, config_key, config_type, config_value, startup_config_value, suggestions, placeholder_str="", password_val="*********") -%} {{ key }} : {% if key == "password" %} {{ password_val }}
{% else %} {{ config[key]|default (config_value) }}
{% endif %} {%- endmacro %} {% macro editable_key_bool(config, key, config_key, config_type, config_value, startup_config_value, suggestions, tooltip) -%}
{%- endmacro %} {% macro editable_key_list(config, key, config_key, config_type, config_value, startup_config_value, suggestions, no_select, force_title, identifier="", placeholder_str="", dict_as_option_group=False) -%} {% if force_title %} {{ key }} : {% endif %} {% if not no_select %}
{% endif %} {%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/evaluator_card.html ================================================ {% import 'macros/tentacles.html' as m_tentacles %} {% macro config_evaluator_card(startup_config, evaluator_name, info, config_type, strategy=False, include_modal=True) %} {% if not strategy %} {% endif %} {{ evaluator_name }} {{('Activation pending restart' if info['activation'] else 'Deactivation pending restart') if (evaluator_name in startup_config and startup_config[evaluator_name] != info['activation']) else ('Activated' if info['activation'] else 'Deactivated')}} {% if include_modal %} {{ evaluator_card_modal(evaluator_name, info, strategy) }} {% endif %} {% endmacro %} {% macro tentacle_evaluator_card(startup_config, evaluator_name, info, config_type, strategy=False) %} {{ evaluator_name }} {{('Activation pending restart' if info['activation'] else 'Deactivation pending restart') if startup_config[evaluator_name] != info['activation'] else ('Activated' if info['activation'] else 'Deactivated')}} {{ evaluator_card_modal(evaluator_name, info, strategy) }} {% endmacro %} {% macro evaluator_card_modal(evaluator_name, info, strategy=False, read_only=False) %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/exchange_card.html ================================================ {% import 'components/config/editable_config.html' as m_editable_config %} {% macro config_exchange_card(config, exchange, exchanges_details, is_supporting_future_trading, keys_value="*********", enabled=True, sandboxed=False, selected_exchange_type='spot', add_class='', config_values=None, full_config=True, lite_config=False) -%}

{{exchange}}

{% if not lite_config %} {% endif %}
Unknown authentication error.
{% if full_config %}

API Key : {{keys_value}}
API Secret : {{keys_value}}
API Pass/UID : {{keys_value}}

{% endif %} {% if not lite_config %}
{% if full_config %}
{% else %} {% for exchange_type in exchanges_details['supported_exchange_types'] %}
{% endfor %}
{% endif %}
{% endif %}
{% if full_config and not lite_config %} {% endif %}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/notification_config.html ================================================ {% import 'components/config/editable_config.html' as m_editable_config %} {% macro config_notification(config, config_name, service_name_list) -%}

Enabled notification events

{% for key in config %} {{ m_editable_config.editable_key( config, key, config_name + "_" + key, "global_config", config[key], config[key], suggestions=service_name_list, placeholder_str="Select notification(s) to enable") }} {% endfor %}

{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/profiles.html ================================================ {% macro profile_details(profile, tentacles_details, strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, read_only=False) %}

About {{ profile.name }} profile:

{{ profile.description }}

{{ profile_auto_update(profile, read_only or profile.read_only) }}
{{ profile_complexity(profile, read_only or profile.read_only) }}
{{ profile_risk(profile, read_only or profile.read_only) }}
{% if not (read_only or profile.read_only) %} {% endif %}

Overview: Built on OctoBot {{tentacles_details["version"]}}

Traded pairs
{% for currency, pairs in get_profile_traded_pairs_by_currency(profile).items() %}
{{ currency }}
{{pairs | join(', ')}}
{% endfor %}
Exchanges {% if get_enabled_trader(profile) %} {{ get_enabled_trader(profile)}} {% endif %}
{% for exchange in get_profile_exchanges(profile) %} {% endfor %}
Tentacles configuration
{% set trading_modes = get_filtered_list(tentacles_details["activation"], strategy_config["trading-modes"]) %} {% if trading_modes %}
Use {% for tentacle in trading_modes %} {{tentacle}} {% endfor %} as {{'a' if trading_modes | length == 1}} trading mode{{'s' if trading_modes | length > 1 }}.
{% endif %} {% set strategies = get_filtered_list(tentacles_details["activation"], strategy_config["strategies"]) %} {% if strategies %}
With {% for tentacle in strategies %} {{tentacle}} {% endfor %} as {{'a' if strategies | length == 1}} strateg{{'ies' if strategies | length > 1 else 'y'}} and
    {% set TAs = get_filtered_list(tentacles_details["activation"], evaluator_config["ta"]) %} {% if TAs %}
  • {% for tentacle in TAs %} {{tentacle}} {% endfor %} as {{'a' if TAs | length == 1}} technical evaluator{{'s' if TAs | length > 1}}.
  • {% endif %} {% set socials = get_filtered_list(tentacles_details["activation"], evaluator_config["social"]) %} {% if socials %}
  • {% for tentacle in socials %} {{tentacle}} {% endfor %} as {{'a' if socials | length == 1}} social evaluator{{'s' if socials | length > 1}}.
  • {% endif %} {% set RTs = get_filtered_list(tentacles_details["activation"], evaluator_config["real-time"]) %} {% if RTs %}
  • {% for tentacle in RTs %} {{tentacle}} {% endfor %} as {{'a' if RTs | length == 1}} real-time evaluator{{'s' if RTs | length > 1}}.
  • {% endif %}
{% endif %}
{% endmacro %} {% macro profile_auto_update(profile, read_only) %}
{{'Enabled' if profile.auto_update else 'Disabled'}}
{% endmacro %} {% macro profile_complexity(profile, read_only) %}
{% if read_only %} {{profile.complexity.name | capitalize}} {% else %} {% endif %}
{% endmacro %} {% macro profile_risk(profile, read_only) %}
{% if read_only %} {{profile.risk.name | capitalize}} {% else %} {% endif %}
{% endmacro %} {% macro profile_tab(current_profile, profile, tentacles_details, strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, OCTOBOT_DOCS_URL) %}

Profile: {{profile.name}}  

{{ profile_details(profile, tentacles_details, strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list) }}
{% endmacro %} {% macro profile_import_modal(next=None) %} {% endmacro %} {% macro profile_overview(profile, current_profile, tentacles_details, strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, read_only=False, reboot=False, onboarding=False) -%}

{{ profile.name | safe }}

{% if profile.profile_id == current_profile.profile_id %}

active

{% endif %}

{% if profile.imported %} {% elif profile.read_only %} {% else %} {% endif %}

{{profile.name}}

{{ profile.description | safe }}

{{ profile_details_modal(profile, tentacles_details, strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, read_only=read_only) }}
{% if profile.profile_id != current_profile.profile_id %} {% else %} {% endif %}
{%- endmacro %} {% macro profile_details_modal(profile, tentacles_details, strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, read_only=False) %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/service_card.html ================================================ {% import 'components/config/editable_config.html' as m_editable_config %} {% macro config_service_card(config, service_name, service, add_class='', no_select=False, default_values=False, extension_name='') -%} {% set service_config_fields = service.get_default_value() %}

{{service_name}}

{% if service.get_website_url() %} {% endif %}

{% for req in service_config_fields %} {{ m_editable_config.editable_key( service_config_fields if (default_values or req not in config[service_name]) else config[service_name], req, "services_" + service_name + "_" + req, "global_config", service_config_fields[req] if default_values else config[service_name][req], service_config_fields[req] if default_values else config[service_name][req], suggestions=service_config_fields[req] if default_values else config[service_name][req], no_select=no_select, number_step=1, force_title=True, tooltip=service.get_fields_description()[req], identifier=service_name, placeholder_str="Add user(s) in whitelist") }} {% endfor %}

{% if service.is_improved_by_extensions() %} The {{service_name}} interface is improved by the {{extension_name}}. {% endif %}

{% for read_only_info in service.get_read_only_info() %}
{{ read_only_info.name }} {% if read_only_info.type.value == "clickable" %} {{ read_only_info.value }} {% elif read_only_info.type.value == "copyable" %} {{ read_only_info.value }} {% elif read_only_info.type.value == "cta" %} {{ read_only_info.value }} {% else %} {{ read_only_info.value }} {% endif %} {% if read_only_info.configuration_path %} {% endif %}
{% endfor %}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/tentacle_card.html ================================================ {% import 'macros/tentacles.html' as m_tentacles %} {% macro config_tentacle_card(name, info, can_be_disabled) %} {{ name }} {{('Activation pending restart' if info['activation'] else 'Deactivation pending restart') if info['startup_config'] != info['activation'] else ('Activated' if info['activation'] else 'Deactivated')}} {% if info['description'] %} {{ tentacle_card_modal(name, info) }} {% endif %} {% endmacro %} {% macro tentacle_card_modal(name, info) %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/tentacle_config_editor.html ================================================ {% macro tentacles_config_editor(name, class_name) %}

Loading configuration

{% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/config/trader_card.html ================================================ {% import 'components/config/editable_config.html' as m_editable_config %} {% macro config_trader_card(config, config_name, trader, add_class='', link='', action=None, footer_text=None) -%}

{{trader}}

{% for key in config %} {{ m_editable_config.editable_key( config, key, config_name + "_" + key, "global_config", config[key], config[key], number_step=0.001, allow_create_for="starting-portfolio") }} {% endfor %} {% if action %}

{% endif %} {% if footer_text %}
{{ footer_text }}
{% endif %}

{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/modals/generic_modal.html ================================================ {% macro create_generic_modal() -%} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/modals/trading_state_modal.html ================================================ {% import 'macros/trading_state.html' as m_trading_state %} {% macro create_trading_state_modal(is_real_trading, enabled_trader) -%} {%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/components/tentacles_packages/tentacles_package_card.html ================================================ {% macro tentacles_package_card(tentacles_package, default_image) -%}

{{tentacles_package.name | replace("_", " ") | capitalize}} {% if tentacles_package.activated %} Activated {% endif %}

{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/config_tentacle.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% set page_title = name %} {% import 'macros/tentacles.html' as m_tentacles %} {% import 'macros/backtesting_utils.html' as m_backtesting_utils %} {% import 'components/config/tentacle_config_editor.html' as m_tentacle_config_editor %} {% import 'components/config/evaluator_card.html' as m_config_evaluator_card %} {% block body %}
{% if tentacle_desc %}
{{ m_tentacles.tentacle_horizontal_description(tentacle_desc, tentacle_type=="strategy") }}

Configuration

{{ m_tentacle_config_editor.tentacles_config_editor(name, "card-body") }}
{% if user_commands %}

Commands

{% for command_action, command_params in user_commands.items() %} {% endfor %}
{% endif %}
{% if not current_profile.read_only and ((tentacle_type == "trading mode" and tentacle_desc['requirements']|length > 1) or tentacle_desc['requirements'] == ["*"]) %} {{ m_tentacles.missing_tentacles_warning(missing_tentacles) }}

Compatible {{"strategies" if tentacle_type == "trading mode" else "evaluators"}}

{% if tentacle_type == "trading mode" %} {% for evaluator_name, info in strategy_config["strategies"].items() %} {% if evaluator_name in tentacle_desc['requirements'] %} {{ m_config_evaluator_card.tentacle_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endif %} {% endfor %} {% else %} {% if "TA" in tentacle_desc["compatible-types"] or tentacle_desc["compatible-types"] == ["*"]%}

Technical analysis

{% for evaluator_name, info in evaluator_config["ta"].items() %} {% if info["evaluation_format"] == "float" %} {{ m_config_evaluator_card.tentacle_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endif %} {% endfor %}

{% endif %} {% if "SOCIAL" in tentacle_desc["compatible-types"] or tentacle_desc["compatible-types"] == ["*"]%}

Social analysis

{% for evaluator_name, info in evaluator_config["social"].items() %} {% if info["evaluation_format"] == "float" %} {{ m_config_evaluator_card.tentacle_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endif %} {% endfor %}

{% endif %} {% if "REAL_TIME" in tentacle_desc["compatible-types"] or tentacle_desc["compatible-types"] == ["*"]%}

Real time analysis

{% for evaluator_name, info in evaluator_config["real-time"].items() %} {% if info["evaluation_format"] == "float" %} {{ m_config_evaluator_card.tentacle_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config") }} {% endif %} {% endfor %}
{% endif %} {% endif %}

{% endif %} {% if is_trading_strategy_configuration %}

Test configuration {% if tentacle_desc["activation"] %} Ready to test {% else %} Activation required {% endif %}   {% if activated_trading_mode %} Current trading mode: {{ activated_trading_mode.get_name() }} {% endif %}

{% if activated_trading_mode and activated_trading_mode.is_backtestable() %} {% if data_files %}
{% if tentacle_desc["activation"] %}
From :
To :
{% else %} Activate this {{ tentacle_type }} to test it {% endif %}
{% else %}

No backtesting data files found. Once you have data files, you will be able to use them here.

{% endif %} {% elif activated_trading_mode %} {% endif %}

{{ m_backtesting_utils.backtesting_report('config_tentacle', OCTOBOT_DOCS_URL, has_open_source_package) }} {% endif %} {% else %}

{{ name }}

Can't find any tentacle named {{ name }}
{% endif %} {% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/data_collector.html ================================================ {% extends "layout.html" %} {% set active_page = "backtesting" %} {% block body %}

 Download exchange data

Exchange :
Start Date :
End Date :
Pair(s) :
Time Frame(s) :

Available backtesting data files

{% for file, description in data_files %} {% if description.start_timestamp %} {% else %} {% endif %} {% endfor %}
Symbol(s) Date of recording Candles Exchange Time frame(s) File Action
{{", ".join(description.symbols)}}{{description.start_date}} to {{description.end_date}} Full{{description.date}} {{description.candles_length}}{{description.exchange}} {{", ".join(description.time_frames)}} {{file}}
{% if not has_open_source_package() %} {% endif %}

Import from data file


{% endblock %} {% block additional_scripts %} {% if alert %} {% endif %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/default/footer.html ================================================ ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/default/navbar.html ================================================ ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/cloud.html ================================================ {% extends "layout.html" %} {% set active_page = "cloud" %} {% block body %}

The next level of market making, with the transparency of OctoBot

OctoBot cloud Market Making is a self-service market making automation platform. It is based on an extension of this open source market making distribution of the OctoBot trading robot, which is being actively developed since 2018.

With OctoBot cloud Market Making:

  • Benefit from advanced liquidity monitoring and strategies
  • Let yourself be guided to configure strategies tailored for your market and exchange
  • Adapt your strategies according to your needs at all time

Using OctoBot cloud for market making is an alternative to this free market making distribution of OctoBot. While it provides more capabilities and advanced features, the software you are currently using is and will remain free to help smaller projects and individuals benefit from market making automation in a simple way.

Follow your market's liquidity

Exchange markets liquidity is measured using via the OctoBot Liquidity Score. This is a 0 to 10 value measuring how well adapted to the trading demand an order book is.

The Liquidity Score:

  • Makes it easy to measure your token's liquidity on each exchange
  • Is automatically adapted to the volume requirement of each market
  • Gives you clear insights on the markets you follow

octobot market making liquidity scores preview
configuration preview

Start your advanced market making strategy

OctoBot cloud Market Making provides you with more advanced market making strategies and makes it easier to configure and visualize your strategy using the built-in order book preview.

More capabilities for better market making:

  • Unlimited orders in the book and order book profiles
  • Ready-made configurations adapted to your market volume
  • Custom budgeting & automated adjustment upon market volume ups and downs to optimize your funds

Your self-service market making platform

Create the right market making strategy for your market by yourself. The OctoBot Market Making team is here to answer your questions and take care of the technical aspect of the bot.

With improved crypto baskets :

  • Personalized support sessions with the team to make the most of the platform
  • Follow your bot and adapt your strategy within seconds
  • Your bot operational at all time on our secure cloud

live bot preview

And more

  • Direct communication channel to quickly answer your questions
  • Priority new features and improvements requests
  • HollaEx-powered exchanges built-in support


{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/cloud_features.html ================================================ {% extends "layout.html" %} {% set active_page = "community" %} {% import "components/community/tentacle_packages.html" as m_tentacle_packages %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% block body %}
{{ m_flash_messages.flash_messages() }} {{ m_tentacle_packages.pending_tentacles_install_modal(has_owned_packages_to_install) }} {{ m_tentacle_packages.waiting_for_owned_packages_to_install_modal(not has_owned_packages_to_install and auto_refresh_packages) }} {{ m_tentacle_packages.select_payment_method_modal(OCTOBOT_EXTENSION_PACKAGE_1_NAME) }}

Improve your bot with the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}}

The {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} improves your OctoBot capabilities and simplifies its use.

It adds the Strategy Designer to your OctoBot, greatly simplifies the TradingView configuration, improves crypto basket investments much more.

The extension is completely optional and bound to your OctoBot account. This means that updating or reinstalling your OctoBot will automatically install your extension as long as you are connected to your OctoBot account.

${{price}}
Lifetime improvement of your OctoBot.
No subscription. Pay with crypto or credit card.
{{ m_tentacle_packages.get_package_button(OCTOBOT_EXTENSION_PACKAGE_1_NAME, is_community_authenticated, has_open_source_package) }}
{% if is_community_authenticated %}
Authenticated as {{current_logged_in_email}}
{% endif %}

Optimize your own strategies with the Strategy Designer

The Strategy Designer is OctoBot's most advanced strategy optimization and backtesting interface.

Using the Strategy Designer, you can:

  • Compare backtesting results of your strategies
  • Visualize your strategies behavior through time
  • Optimize your strategy while using a different live profile

OctoBot cloud

Seamlessly connect your TradingView webhook

In the default version of OctoBot, advanced technical knowledge or a paid external webhook provider such as Ngrok is required to connect to your TradingView alerts.

Using OctoBot with the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} makes TradingView strategies automation:
  • Free: No TradingView subscription is required to use email alerts, no Ngrok subscription is needed for webhooks
  • Easy: You get a unique alert email address and webhook URL you can use rightaway
  • Secure: Use the OctoBot cloud secure email and webhook system

Use and configure your improved crypto baskets

OctoBot cloud crypto baskets are special strategies that make it easy to invest in the top crypto of the market or specific themes.

With improved crypto baskets :

  • Follow automatically updated OctoBot cloud crypto baskets
  • Or start from an existing basket and customize it
  • Watch your portfolio automatically follow the latest trends

OctoBot cloud

And more!

With the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} :

  • Invest using exclusive strategies and profiles
  • Join the exclusive Discord channel

{{ m_tentacle_packages.get_package_button(OCTOBOT_EXTENSION_PACKAGE_1_NAME, is_community_authenticated, has_open_source_package) }}
Any question on the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} ? Just ask the team.

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/configuration.html ================================================ {% extends "layout.html" %} {% set active_page = "configuration" %} {% import 'components/config/tentacle_config_editor.html' as m_tentacle_config_editor %} {% macro save_block(display_interfaces_link) %}
{% if display_interfaces_link %} {% endif %}
{% endmacro %} {% block body %}
{{tentacle_docs}}

Exchange and Trading pair

{{ m_tentacle_config_editor.tentacles_config_editor(trading_mode_name) }}
{{ save_block(False) }}

Trading simulator configuration

Exchanges configuration

Add the exchange to perform market making on as well as the exchange used as "Reference exchange".

Note: For trading simulator and reference exchanges, exchanges must be added, and API details are not required.

Click save after adding a new exchange to be able to select it.

{{ save_block(True) }}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/dashboard.html ================================================ {% extends "layout.html" %} {% set active_page = "home" %} {% import 'macros/critical_notifications_alert.html' as m_critical_notifications_alert %} {% macro waiter(waiter_id, title) %}

{{title}}

{% endmacro %} {% block body %}
{% if display_ph_launch %} {% endif %} {{ m_critical_notifications_alert.critical_notifications_alert(critical_notifications) }}
{% if sandbox_exchanges %}
{{sandbox_exchanges[0] | capitalize}} sandbox
{% endif %}

Market making enabled

OctoBot is starting, markets will be refreshed when exchanges will be reachable.

If this loader remains, please make sure that at least one exchange is enabled in your profile.


Open orders

{{ waiter("orders-waiter", "Loading orders") }}

Portfolio value
{{reference_unit}} %

Your daily portfolio value history will be displayed here.

Trades history

{{ waiter("trades-waiter", "Loading trades") }}
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/footer.html ================================================ ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/interfaces.html ================================================ {% extends "layout.html" %} {% set active_page = "configuration" %} {% import 'components/config/service_card.html' as m_config_service_card %} {% import 'components/config/notification_config.html' as m_config_notification %} {% block additional_style %} {% endblock additional_style %} {% block body %}

Interfaces

Select an interface :

{% for service in services_list %} {% if service in config_services %} {{ m_config_service_card.config_service_card(config_services, service, services_list[service], extension_name=OCTOBOT_EXTENSION_PACKAGE_1_NAME) }} {% endif %} {% endfor %}

Notifications

{{ m_config_notification.config_notification(config_notifications, "notification", notifiers_list) }}
{% for service in services_list %}
{{ m_config_service_card.config_service_card( config_services, service, services_list[service], add_class=added_class, no_select=True, default_values=True, extension_name=OCTOBOT_EXTENSION_PACKAGE_1_NAME) }}
{% endfor %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/navbar.html ================================================ ================================================ FILE: Services/Interfaces/web_interface/templates/distributions/market_making/portfolio.html ================================================ {% extends "layout.html" %} {% set active_page = "portfolio" %} {% import 'macros/cards.html' as m_cards %} {% import 'macros/starting_waiter.html' as m_waiter %} {% block body %}
{% macro display_init_warning() -%} {% if initializing_currencies_prices %} {% endif %} {%- endmacro %} {% macro holding_row(holdings, holding_type) -%} {{holdings[holding_type]}} {%- endmacro %} {% macro portfolio_holding(currency, holdings, value) -%}
{{currency}}
{{currency}}
{{ holding_row(holdings, "total") }} {{value}} {{ holding_row(holdings, "free") }} {{ holding_row(holdings, "locked") }} {%- endmacro %}
{% if not has_real_trader and not has_simulated_trader %} {{ m_waiter.display_loading_message(details="If this message remains, please make sure that at least one exchange is enabled in your profile.") }} {% else %}
{{ display_init_warning() }}

Portfolio: {{displayed_portfolio_value}} {{reference_unit}}

{% if displayed_portfolio %}
{% for currency, holdings in displayed_portfolio.items() %} {{ portfolio_holding(currency, holdings, symbols_values[currency]) }} {% endfor %}
Asset Total Value in {{reference_unit}} Available Locked in orders
{% else %}

Nothing there.

If a trader is enabled, please check your OctoBot logs. There might be an issue with your exchange credentials.

{% endif %}
{% endif %}
{% endblock %}
{% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/dsl_help.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% block body %}

OctoBot DSL Help

The OctoBot DSL is a domain-specific language for creating scripts that can be used in the OctoBot platform. It is based on the Python syntax and adds custom functions and keywords to easily create executable expressions.

Keywords can be combined to create more complex expressions.

Syntax and examples

The OctoBot DSL syntax is based on the Python syntax, which includes base operators like +, -, *, /, %, ==, !=, >, <, >=, <=, and, or.

Examples:
  • "close("BTC/USDT", "1h")[-1]" returns the close price of the last 1h candle of the "BTC/USDT" market
  • "close("BTC/USDT", "1h")[-1] * 2 + 10" returns the close price of the last 1h candle of the "BTC/USDT" market multiplied by 2 and then added to 10
  • "ma(close("BTC/USDT", "1h"), 12)[-1]" returns the 12-period moving average of the close prices of the last 1h candles of the "BTC/USDT" market
  • "100 if close("BTC/USDT", "1h")[-1] > open("BTC/USDT", "1h")[-1] else ((1 + 2) * 3)" returns 100 if the close price of the last 1h candle of the "BTC/USDT" market is greater than the open price of the last 1h candle of the "BTC/USDT" market, otherwise 9

Keywords

The following keywords are available in the OctoBot DSL:

Keyword Description Example Type

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/extensions.html ================================================ {% extends "layout.html" %} {% set active_page = "community" %} {% import "components/community/tentacle_packages.html" as m_tentacle_packages %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% block body %}
{{ m_flash_messages.flash_messages() }} {{ m_tentacle_packages.pending_tentacles_install_modal(has_owned_packages_to_install) }} {{ m_tentacle_packages.waiting_for_owned_packages_to_install_modal(not has_owned_packages_to_install and auto_refresh_packages) }} {{ m_tentacle_packages.select_payment_method_modal(OCTOBOT_EXTENSION_PACKAGE_1_NAME) }}

Improve your bot with the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}}

The {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} improves your OctoBot capabilities and simplifies its use.

It adds the Strategy Designer to your OctoBot, greatly simplifies the TradingView configuration, improves crypto basket investments much more.

The extension is completely optional and bound to your OctoBot account. This means that updating or reinstalling your OctoBot will automatically install your extension as long as you are connected to your OctoBot account.

${{price}}
Lifetime improvement of your OctoBot.
No subscription. Pay with crypto or credit card.
{{ m_tentacle_packages.get_package_button(OCTOBOT_EXTENSION_PACKAGE_1_NAME, is_community_authenticated, has_open_source_package) }}
{% if is_community_authenticated %}
Authenticated as {{current_logged_in_email}}
{% endif %}

Optimize your own strategies with the Strategy Designer

The Strategy Designer is OctoBot's most advanced strategy optimization and backtesting interface.

Using the Strategy Designer, you can:

  • Compare backtesting results of your strategies
  • Visualize your strategies behavior through time
  • Optimize your strategy while using a different live profile

OctoBot cloud

Seamlessly connect your TradingView webhook

In the default version of OctoBot, advanced technical knowledge or a paid external webhook provider such as Ngrok is required to connect to your TradingView alerts.

Using OctoBot with the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} makes TradingView strategies automation:
  • Simple: Use OctoBot cloud webhooks - no Ngrok subscription is needed for webhooks
  • Easy: You get a unique alert email address and webhook URL you can use rightaway
  • Secure: Use the OctoBot cloud secure email and webhook system

Use and configure your improved crypto baskets

OctoBot cloud crypto baskets are special strategies that make it easy to invest in the top crypto of the market or specific themes.

With improved crypto baskets :

  • Follow automatically updated OctoBot cloud crypto baskets
  • Or start from an existing basket and customize it
  • Watch your portfolio automatically follow the latest trends

OctoBot cloud

And more!

With the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} :

  • Invest using exclusive strategies and profiles
  • Join the exclusive Discord channel

{{ m_tentacle_packages.get_package_button(OCTOBOT_EXTENSION_PACKAGE_1_NAME, is_community_authenticated, has_open_source_package) }}
Any question on the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} ? Just ask the team.

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/index.html ================================================ {% extends "layout.html" %} {% set active_page = "home" %} {% import 'macros/critical_notifications_alert.html' as m_critical_notifications %} {% block body %}
{% if display_ph_launch %} {% endif %} {% if not IS_CLOUD %} {% endif %} {% if display_trading_delay_info %} {% endif %} {% if is_in_stating_community_env() %}

Welcome to the OctoBot beta environment

When the beta environment is enabled, you will be connected to the "in development" version of OctoBot cloud ({{OCTOBOT_COMMUNITY_URL}}). The beta OctoBot cloud has its own accounts and products. Please login using your beta account.
{% endif %} {{ m_critical_notifications.critical_notifications_alert(critical_notifications) }}

Portfolio value
{{reference_unit}} %

Your daily portfolio value history will be displayed here.

{% if sandbox_exchanges %}
{{sandbox_exchanges[0] | capitalize}} sandbox
{% endif %}

Watched markets

OctoBot is starting, markets will be refreshed when exchanges will be reachable.

If this loader remains, please make sure that at least one exchange is enabled in your profile.

{% if backtesting_mode %}
{% else %} {% for pair in watched_symbols %}
{% endfor %}
{% if not watched_symbols %} {% endif %} {% endif %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/layout.html ================================================ {% import 'macros/trading_state.html' as m_trading_state %} {% import 'components/modals/trading_state_modal.html' as m_trading_state_modal %} {% import 'components/modals/generic_modal.html' as m_generic_modal %} {% import 'components/community/user_details.html' as m_user_details %} {% set active_page = active_page|default('home') -%} {% set page_title = page_title|default(active_page | replace("_", " ") | capitalize) -%} {% set startup_messages_added_classes = startup_messages_added_classes|default('') -%} {% set inner_startup_messages_added_classes = inner_startup_messages_added_classes|default('col-12') -%} {{ page_title }} - OctoBot {% block additional_meta %} {% endblock additional_meta %} {% block additional_style %} {% endblock additional_style %} {# docs in http://w2ui.com/web/demos/#/grid/1 #} {{ m_user_details.posthog(IS_DEMO, IS_CLOUD, IS_ALLOWING_TRACKING, PH_TRACKING_ID) }} {% set show_nab_bar = show_nab_bar|default(True) -%} {% if show_nab_bar %} {% if get_distribution() == 'market_making' %} {% include "distributions/market_making/navbar.html" %} {% else %} {% include "distributions/default/navbar.html" %} {% endif %} {% endif %}
{% if startup_messages %}
{% for startup_message in startup_messages %}
{{ startup_message }}
{% endfor %}
{% endif %} {% block body %}{% endblock %}
{{ m_trading_state_modal.create_trading_state_modal(is_real_trading(get_current_profile()), get_enabled_trader(get_current_profile())) }} {{ m_generic_modal.create_generic_modal() }}
{% if get_distribution() == 'market_making' %} {% include "distributions/market_making/footer.html" %} {% else %} {% include "distributions/default/footer.html" %} {% endif %} {% block additional_scripts %} {% endblock additional_scripts %} {{ m_user_details.user_details( USER_EMAIL, USER_SELECTED_BOT_ID, has_open_source_package, PROFILE_NAME, TRADING_MODE_NAME, EXCHANGE_NAMES, IS_REAL_TRADING ) }} ================================================ FILE: Services/Interfaces/web_interface/templates/login.html ================================================ {% extends "layout.html" %} {% set active_page = "home" %} {% from "macros/forms.html" import render_field %} {% block body %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/logs.html ================================================ {% extends "layout.html" %} {% set active_page = "logs" %} {% import 'macros/tables.html' as m_tables %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% macro extract_logs(logs_list) -%} {% for log in logs_list %} {{ m_tables.logs_tr(log) }} {% endfor %} {%- endmacro %} {% macro extract_notifications(notifications_list) -%} {% for notification in notifications_list %} {{ m_tables.notifications_tr(notification) }} {% endfor %} {%- endmacro %} {% block body %}

Logs & Notifications

{{ m_flash_messages.flash_messages() }}
{{ extract_logs(logs) }}
Find the full current and previous OctoBot executions information in logs/OctoBot.log files.
Time Level Source Message
{{ extract_notifications(notifications) }}
History of notifications you enabled as web interface and/or telegram notifications.
Time Title Message Type
{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/backtesting_utils.html ================================================ {% macro backtesting_report(source, OCTOBOT_DOCS_URL, has_open_source_package) -%}

{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/cards.html ================================================ {% macro pair_status_card(pair, status, watched_symbols, displayed_portfolio, symbols_values, ref_market) -%} {% set symbol = pair.split('/')[0] %}
{{ pair }}
{{ symbol }}
{{ ref_market }} equiv.
{{ displayed_portfolio[symbol]["total"] if symbol in displayed_portfolio else 0 }}
{{ symbols_values[symbol] if symbol in symbols_values else 0 }}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/critical_notifications_alert.html ================================================ {% macro critical_notifications_alert(critical_notifications, maxDisplayed=5) %} {% for notification in critical_notifications[0:maxDisplayed] %} {% endfor %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/flash_messages.html ================================================ {% macro flash_messages() %} {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} {% endfor %} {% endif %} {% endwith %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/forms.html ================================================ {% macro render_field(field) %} {{ field(**kwargs)|safe }} {% if field.errors %}
{% for error in field.errors %}
{{ error }}
{% endfor %}
{% endif %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/major_issue_alert.html ================================================ {% macro major_issue_alert(major_issue_alerts) %} {% for alert in major_issue_alerts %} {% endfor %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/starting_waiter.html ================================================ {% macro display_loading_message(text=None, details=None, next_url=None) -%}

{{ text or "OctoBot is starting, please refresh this page in a few seconds." }}

{% if details %}

{{ details }}

{% endif %}
{%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/tables.html ================================================ {% macro order_tr(order, type='', timestamp='', sim_or_real='Simulated') -%} {{ order.symbol }} {{ type.replace("_", " ")}} {{ order.origin_price if not order.origin_stop_price else order.origin_stop_price}} {{ order.origin_quantity }} {{ order.exchange_manager.exchange.name if order.exchange_manager else '' }} {{ timestamp }} {{ order.total_cost }} {{ order.market }} {% if sim_or_real == 'Simulated' %} {{ sim_or_real }} {% else %} {{ sim_or_real }} {{"(virtual)" if order.is_self_managed()}} {% endif %} {%- endmacro %} {% macro position_tr(position, sim_or_real='Simulated') -%} {% if not position.is_idle() %} {{ position.side.value | upper }} {{ position.symbol_contract }} {{ position.size | round(5) }} {{ position.value | round(5) }} {{ position.currency if position.symbol_contract.is_inverse_contract() else position.market }} {{ position.entry_price | round(5) }} {{ position.liquidation_price | round(5) }} {{ position.margin | round(5) }} {{ position.currency if position.symbol_contract.is_inverse_contract() else position.market }} {{ position.unrealized_pnl | round(5) }} {{ position.currency if position.symbol_contract.is_inverse_contract() else position.market }} ({{ position.get_unrealized_pnl_percent() | round(5) }}%) {{ position.exchange_manager.exchange.name if position.exchange_manager else '' }} {{ sim_or_real }} {% endif %} {%- endmacro %} {% macro trades_tr(trade, type='', timestamp='', sim_or_real='Simulated') -%} {{ trade.symbol }} {{ type.replace("_", " ")}} {{ trade.executed_price }} {{ trade.executed_quantity }} {{ trade.exchange_manager.exchange.name if trade.exchange_manager else ''}} {{ trade.total_cost }} {{ trade.market }} {{ trade.fee['cost'] }} {{ trade.fee['currency'] }} {{ timestamp }} {{ trade.trade_id }} {{ sim_or_real }} {%- endmacro %} {% macro logs_tr(log) -%} {{ log["Time"] }} {{ log["Level"] }} {{ log["Source"] }} {{ log["Message"] }} {%- endmacro %} {% macro notifications_tr(notification) -%} {{ notification["Time"] }} {{ notification["Title"] }} {{ notification["Message"] }} {{ notification["Level"] }} {%- endmacro %} {% macro top_tr(item) -%} {{ item["rank"] }} {{ item["name"] }} {{ item["count"] }} {%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/tentacles.html ================================================ {% import 'macros/text.html' as m_text %} {% macro tentacle_description(info, strategy, name, read_only=False) %} {{info['description']}} {% if not read_only and info['default-config'] %}

{{ default_config_with_apply(info['default-config'], strategy, name, info['requirements']) }}

{% endif %} {% endmacro %} {% macro tentacle_horizontal_description(info, strategy) %}
{{ tentacle_horizontal_description_row_content(info, strategy, not info['requirements']) }} {% if info['requirements'] %}
{{ requirements_and_default_config(info['requirements'], info['default-config'], strategy, info['name']) }}
{% endif %}
{% endmacro %} {% macro tentacle_horizontal_description_row_content(info, strategy, use_all_cols) %} {% if info['description'] %}
{{info['description']}}
{% endif %} {% endmacro %} {% macro tentacle_with_link(tentacle_name) %} {{ tentacle_name }} {% endmacro %} {% macro tentacle_with_link_list(tentacle_list) %} {% if tentacle_list == ["*"] %} All tentacles returning values between -1 and 1. {% else %} {% for tentacle in tentacle_list %} {{ tentacle_with_link(tentacle) }} {% endfor %} {% endif %} {% endmacro %} {% macro default_config_with_apply(default_config, strategy, name, requirements) %}
Default configuration: {% if strategy or (not strategy and requirements|length > 1) %} {% endif %}
{{ tentacle_with_link_list(default_config) }} {% endmacro %} {% macro requirements_and_default_config(requirements, default_config, strategy, name) %}
Compatible {{ 'evaluators' if strategy else 'strategies' }}:
{{ tentacle_with_link_list(requirements) }}
{{ default_config_with_apply(default_config, strategy, name, requirements) }} {% endmacro %} {% macro missing_tentacles_warning(missing_tentacles) %} {% if missing_tentacles %} {% endif %} {% endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/text.html ================================================ {% macro text_lines(lines) -%} {% for line in lines %}

{{ line }}

{% endfor %} {%- endmacro %} {% macro text_split_lines(text) -%} {% for line in text.split("\n") %} {% if not loop.first %}
{% endif %} {{ line }} {% endfor %} {%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/macros/trading_state.html ================================================ {% macro display_trading_state(is_real_trading, enabled_trader, hide_on_small=True, use_bold_for_title=False) -%} {{ enabled_trader }} {%- endmacro %} ================================================ FILE: Services/Interfaces/web_interface/templates/octobot_help.html ================================================ {% extends "layout.html" %} {% set active_page = "help" %} {% block body %}

Understanding OctoBot

Tutorials

Reset introduction tutorials

Help buttons:

When using OctoBot, you will find these buttons: . They are triggering the in page help and contain links to the OctoBot website or OctoBot guides explaining to the associated element.

Frequently asked questions

We keep track of many of our community users questions so that everyone can benefit from the answers in our dedicated FAQ.

Troubleshoot

Some issues are pretty common and sometimes they are due to factors that are external to OctoBot. In the troubleshoot section you will find many possible issues happening on various situations and how to fix them.

OctoBot cloud

In the OctoBot website, you will find many resources on various subjects including:
  • What is the OctoBot Project
  • In depth insight regarding OctoBot, its design and philosophy

OctoBot guides

In the OctoBot guides, you will find many articles to help you use OctoBot including:
  • Video guides on OctoBot's setup and main features
  • Different ways to install OctoBot on your own computer or on the cloud
  • OctoBot configuration
  • OctoBot strategies and trading modes
  • Supported exchanges
  • Advanced resources on OctoBot architecture, development guides and specific features
  • OctoBot Script

Telegram and Discord channels

In case none of the above resources helped you to solve your issue or answer your question, you can always ask for help in the Telegram or Discord community channels.


{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/portfolio.html ================================================ {% extends "layout.html" %} {% set active_page = "portfolio" %} {% import 'macros/cards.html' as m_cards %} {% import 'macros/starting_waiter.html' as m_waiter %} {% block body %}
{% macro display_init_warning() -%} {% if initializing_currencies_prices %} {% endif %} {%- endmacro %} {% macro holding_row(holdings, holding_type) -%} {{holdings[holding_type]}} {%- endmacro %} {% macro portfolio_holding(currency, holdings, value) -%}
{{currency}}
{{currency}}
{{ holding_row(holdings, "total") }} {{value}} {{ holding_row(holdings, "free") }} {{ holding_row(holdings, "locked") }} {%- endmacro %}
{% if not has_real_trader and not has_simulated_trader %} {{ m_waiter.display_loading_message(details="If this message remains, please make sure that at least one exchange is enabled in your profile.") }} {% else %}
{{ display_init_warning() }}

Portfolio: {{displayed_portfolio_value}} {{reference_unit}}

{% if displayed_portfolio %}
{% for currency, holdings in displayed_portfolio.items() %} {{ portfolio_holding(currency, holdings, symbols_values[currency]) }} {% endfor %}
Asset Total Value in {{reference_unit}} Available Locked in orders
{% else %}

Nothing there.

If a trader is enabled, please check your OctoBot logs. There might be an issue with your exchange credentials.

{% endif %}
{% endif %}
{% endblock %}
{% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/profile.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% set startup_messages_added_classes = "justify-content-end px-4" %} {% set inner_startup_messages_added_classes = "offset-md-3 offset-lg-2 offset-1" %} {% import 'components/config/exchange_card.html' as m_config_exchange_card %} {% import 'components/config/currency_card.html' as m_config_currency_card %} {% import 'components/config/trader_card.html' as m_config_trader_card %} {% import 'components/config/evaluator_card.html' as m_config_evaluator_card %} {% import 'components/config/tentacle_card.html' as m_config_tentacle_card %} {% import 'components/config/profiles.html' as m_config_profile_tab %} {% import 'macros/tentacles.html' as m_tentacles %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% set config_default_value = "Bitcoin" %} {% set config_default_symbol = "btc" %} {% set added_class = "new_element" %} {% block additional_style %} {% endblock additional_style %} {% block body %}
{{ m_flash_messages.flash_messages() }}
{% if not strategy_config["trading-modes"] %} {% endif %}

Current profile {{current_profile.name}}

{% for profile_id, profile in profiles.items() %} {{m_config_profile_tab.profile_tab(current_profile, profile, profiles_tentacles_details[profile_id], strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, OCTOBOT_DOCS_URL)}} {% endfor %}
{% if profiles_tentacles_details[current_profile.profile_id]["read_error"] %} {% elif profiles_tentacles_details[current_profile.profile_id]["version"] != CURRENT_BOT_VERSION and not profiles_tentacles_details[current_profile.profile_id]["imported"] and profiles_tentacles_details[current_profile.profile_id]["require_exact_version"]%} {% endif %} {% set trading_modes = strategy_config["trading-modes"].items() %} {% set strategies = strategy_config["strategies"].items() %} {% if not current_profile.read_only %}

Trading modes

{% for trading_mode_name, info in trading_modes %} {{ m_config_evaluator_card.config_evaluator_card(trading_startup_config, trading_mode_name, info, "trading_config", include_modal=False) }} {% endfor %}

Compatible evaluation strategies

This trading mode doesn't need any strategy.

{% for evaluator_name, info in strategies %} {{ m_config_evaluator_card.config_evaluator_card(evaluator_startup_config, evaluator_name, info, "evaluator_config", strategy=True, include_modal=False) }} {% endfor %}

{{ m_tentacles.missing_tentacles_warning(missing_tentacles) }} {% else %}

Profile strategy configuration read only

{% for trading_mode_name, info in trading_modes %} {% if info['activation'] %}

{{ trading_mode_name }}

{{ m_tentacles.tentacle_horizontal_description_row_content(info, tentacle_type=="strategy", True) }}
{% endif %} {% endfor %} {% for evaluator_name, info in strategies %} {% if info['activation'] %}

{{ evaluator_name }}

{{ m_tentacles.tentacle_horizontal_description_row_content(info, tentacle_type=="strategy", True) }}
{% endif %} {% endfor %}
{% endif %} {% if not has_open_source_package() %}
The {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} contains many other configuration profiles and enables your open source OctoBot to use and customize OctoBot cloud's automatically configured indexes.
{% endif %}

Currencies

{% if in_backtesting %} {% endif %}

{% if not symbol_list_by_type %}
{% endif %} {% if "future" in enabled_exchange_types %} {% endif %}
{% for crypto_currency in config_symbols %} {{ m_config_currency_card.config_currency_card(config_symbols, crypto_currency, filter_currency_pairs(crypto_currency, symbol_list_by_type, full_symbol_list, config_symbols), full_symbol_list, get_currency_id, symbol=config_symbols[crypto_currency]['pairs'][0].split('/')[0].lower() if config_symbols[crypto_currency]['pairs'])}} {% endfor %}

Exchanges

{% if "future" in enabled_exchange_types %} {% endif %}
{% for exchange in config_exchanges %} {{ m_config_exchange_card.config_exchange_card(config_exchanges, exchange, exchanges_details[exchange], is_supporting_future_trading, enabled=config_exchanges[exchange].get('enabled', True), sandboxed=config_exchanges[exchange].get('sandboxed', False), selected_exchange_type=config_exchanges[exchange].get('exchange-type', exchanges_details[exchange]['default_exchange_type']), full_config=False) }} {% endfor %}

Trading {{ 'Real trader' if real_trader_activated else 'Simulator' }}

{{ m_config_trader_card.config_trader_card(config_trading, "trading", "Trading settings", link=OCTOBOT_DOCS_URL+"/octobot-configuration/profile-configuration#trading") }} {{ m_config_trader_card.config_trader_card(config_trader, "trader", "Trader", link=OCTOBOT_DOCS_URL+"/octobot-usage/simulator#real-trader") }} {{ m_config_trader_card.config_trader_card(config_trader_simulator, "trader-simulator", "Trader simulator", link=OCTOBOT_DOCS_URL+"/octobot-usage/simulator?utm_source=octobot&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=simulator_config", footer_text="Changes in the simulated starting portfolio will reset enabled exchanges simulated portfolio history.") }}
{% if config_tentacles_by_group or other_tentacles_config %}
{% for config_tentacle in other_tentacles_config %} {{ m_config_tentacle_card.config_tentacle_card(config_tentacle["name"], config_tentacle, False) }} {% endfor %}

{% for group, config_tentacles in config_tentacles_by_group.items() %}

{{group.replace("_", " ")}}

{% for config_tentacle in config_tentacles %} {{ m_config_tentacle_card.config_tentacle_card(config_tentacle["name"], config_tentacle, True) }} {% endfor %}

{% endfor %}
{% endif %}

Automations

Automations are actions that will be triggered automatically when something happens. You can have as many automations as you want.
Your current profile has {{automations_count}} automation{{'s' if automations_count > 1 else ''}}.

{% for trading_mode_name, info in strategy_config["trading-modes"].items() %} {{ m_config_evaluator_card.evaluator_card_modal(trading_mode_name, info, False) }} {% endfor %} {% for evaluator_name, info in strategy_config["strategies"].items() %} {{ m_config_evaluator_card.evaluator_card_modal(evaluator_name, info, True) }} {% endfor %} {% for evaluator_type_items in ['ta', 'social', 'real-time'] %} {% for evaluator_name, info in evaluator_config[evaluator_type_items].items() %} {{ m_config_evaluator_card.evaluator_card_modal(evaluator_name, info, True) }} {% endfor %} {% endfor %} {{ m_config_profile_tab.profile_import_modal() }}
{{ m_config_currency_card.config_currency_card( config_symbols={config_default_value: {"enabled": true, "pairs": [] } }, crypto_currency=config_default_value, symbol_list_by_type=symbol_list_by_type, full_symbol_list=full_symbol_list, get_currency_id=get_currency_id, add_class=added_class, no_select=True, additional_classes="default", symbol= config_default_symbol ) }}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/profiles_selector.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% set startup_messages_added_classes = "d-none" %} {% import "components/community/login.html" as login %} {% import 'components/config/evaluator_card.html' as m_config_evaluator_card %} {% import 'components/community/cloud_strategies_selector.html' as m_cloud_strategies_selector %} {% import "components/config/profiles.html" as profiles_macros %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% import 'macros/starting_waiter.html' as m_starting_waiter %} {% block body %}
{% if not current_logged_in_email %} {% endif %}

Select the profile your OctoBot should use

Use ready-made profiles from the open-source OctoBot and your custom profiles.

{% for profile in profiles %} {{ profiles_macros.profile_overview(profile, current_profile, profiles_tentacles_details[profile.profile_id], strategy_config, evaluator_config, get_profile_traded_pairs_by_currency, get_profile_exchanges, get_enabled_trader, get_filtered_list, read_only, True, onboarding) }} {% endfor %}

Use OctoBot cloud strategies directly from your OctoBot.

{{ m_cloud_strategies_selector.cloud_strategies_selector(cloud_strategies, LOCALE, "select-profile") }}

{{ profiles_macros.profile_import_modal(url_for('profiles_selector')) }} {% for trading_mode_name, info in strategy_config["trading-modes"].items() %} {{ m_config_evaluator_card.evaluator_card_modal(trading_mode_name, info, False, read_only) }} {% endfor %} {% for evaluator_name, info in strategy_config["strategies"].items() %} {{ m_config_evaluator_card.evaluator_card_modal(evaluator_name, info, True, read_only) }} {% endfor %} {% for evaluator_type_items in ['ta', 'social', 'real-time'] %} {% for evaluator_name, info in evaluator_config[evaluator_type_items].items() %} {{ m_config_evaluator_card.evaluator_card_modal(evaluator_name, info, True, read_only) }} {% endfor %} {% endfor %} {% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: Services/Interfaces/web_interface/templates/symbol_market_status.html ================================================ {% extends "layout.html" %} {% set active_page = "trading" %} {% block body %}

 

{{currency}}

 {{symbol}} on {{exchange}}: {{symbol_evaluation}}

Time frame


{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/terms.html ================================================ {% extends "layout.html" %} {% set active_page = "about" %} {% set show_nab_bar = accepted_terms %} {% import 'macros/text.html' as m_text %} {% block body %}

Disclaimer

{{ m_text.text_lines(disclaimer) }}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/trading.html ================================================ {% extends "layout.html" %} {% set active_page = "trading" %} {% set startup_messages_added_classes = "justify-content-end px-4" %} {% set inner_startup_messages_added_classes = "offset-md-3 offset-lg-2 offset-1" %} {% import 'macros/cards.html' as m_cards %} {% import 'macros/starting_waiter.html' as m_waiter %} {% set vars = {'exchange_overload': False} %} {% macro exchange_overload_warning(exchange, load) -%} {% if load["has_reached_websocket_limit"] %} {% elif load["overloaded"] %} {% set _ = vars.update({'exchange_overload': True}) %} {% endif %} {%- endmacro %} {% macro waiter(waiter_id, title) %}

{{title}}

{% endmacro %} {% block body %}
{% if not (pairs_with_status or has_real_trader) %} {{ m_waiter.display_loading_message(details="If this message remains, please make sure that at least one exchange is enabled in your profile.") }} {% else %} {% for exchange, load in exchanges_load.items() %} {{ exchange_overload_warning(exchange, load) }} {% endfor %} {% if vars['exchange_overload'] %} {% endif %}

Profit and Loss

{{ waiter("pnl-waiter", "Loading Profit and Loss") }}
{% if might_have_positions %}
When trading futures, PNL might be incorrect. The team is working on fixing this issue.
{% endif %}

Matched Trades

For accuracy, Profit and Loss is only computed for trades that are coming from a trading mode that is supporting PNL history such as Grid Trading or the Dip Analyser.
Please check trading modes description to find out if PNL history is supported. Resetting the trades history will reset Profit and Loss.

Open orders

{{ waiter("orders-waiter", "Loading orders") }}
{% if might_have_positions %}

Positions

{{ waiter("positions-waiter", "Loading positions") }}

{% endif %}

Market status

{% for pair, status in pairs_with_status.items() | sort(attribute='0') %} {{ m_cards.pair_status_card(pair, status, watched_symbols, displayed_portfolio, symbols_values, reference_market) }} {% endfor %}

 Trades history

{{ waiter("trades-waiter", "Loading trades") }}
{% if followed_strategy_url %}
{% endif %}

{% endif %} {% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/trading_type_selector.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% set startup_messages_added_classes = "d-none" %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% import 'macros/starting_waiter.html' as m_starting_waiter %} {% import 'components/config/exchange_card.html' as m_config_exchange_card %} {% block body %}

Final step: select how to trade using {{current_profile_name}}

{{ m_flash_messages.flash_messages() }}
Select the exchange to use :

{{enabled_exchanges[0]}}

{{ m_config_exchange_card.config_exchange_card(config_exchanges, enabled_exchanges[0], exchanges_details[enabled_exchanges[0]], is_supporting_future_trading, enabled=True, sandboxed=False, selected_exchange_type=config_exchanges[enabled_exchanges[0]].get('exchange-type', 'spot'), full_config=True, lite_config=True)}}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/tradingview_email_config.html ================================================ {% extends "layout.html" %} {% set active_page = "accounts" %} {% import "components/community/tentacle_packages.html" as m_tentacle_packages %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% macro tutorial_article(title, image, details) -%}

{{ title | safe }}

{% if image %}
tutorial illustration
{% endif %}

{{ details | safe }}

{%- endmacro %} {% block body %}
{{ m_flash_messages.flash_messages() }}

Configure OctoBot to trade using TradingView email alerts

{% if not is_community_authenticated %}

Please login to your OctoBot account to configure your TradingView email alerts.

Login

Note: The {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}} is required to connect your OctoBot to TradingView email alerts.

{% elif has_open_source_package() %}
{{ tutorial_article( "Trading using email alerts", "img/tradingview/tradingview-logo.png", "Follow those steps to add your OctoBot TradingView email address to your TradingView account " "if your TradingView alert email is unset or different from:

"+ tradingview_email_address + "

" ) }}
{{ tutorial_article( "1. Create or edit an alert", "img/tradingview/tradingview-create-alert.png", "Create / edit an alert to open the alert configuration view" ) }} {{ tutorial_article( "", "img/tradingview/create-alert-view.png", "" ) }}
{{ tutorial_article( "2. Go to the Notifications tab and select Send plain text", "img/tradingview/tradingview-alert-notification-email-selected-form.png", "Go to the Notifications tab and select Send plain text.
This will open the 'Account verification' modal.

Note: if the modal doesn't show up, it means that an alert email address is already set. In this case, go to your profile settings and edit your Alternative email for alerts.

" ) }}
{{ tutorial_article( "3. Enter your OctoBot alert email address", "img/tradingview/tradingview-alert-email-form.png", "Enter your OctoBot alert email address:

"+ tradingview_email_address + "

and click 'Get code'.

" ) }}
{{ tutorial_article( "4. Enter your verification code", "img/tradingview/tradingview-alert-email-form-confirm-code.png", "Email address: "+ tradingview_email_address + "
✅ Your verification code is on the way, it will be displayed here ...
Enter your verification code:


Receiving the code may take up to 2 minute.
😦 Receiving the code is taking too long. Can you please double-check the email address?
Email address: "+ tradingview_email_address + "

Please contact the support if your believe this is an issue with OctoBot.

" ) }}
{{ tutorial_article( "You are all set!", "img/tradingview/tradingview-alert-email-form-completed.png", "🎉 TradingView will now notify your OctoBot using emails when your alerts will fire." ) }} {{ tutorial_article( "Last words", "img/tradingview/use-email-alerts.png", "
  • Remember to check Use-Email-Alerts in your TradingView interface configuration to make your OctoBot listen to email alerts.
  • Selecting a TradingView-related profile such as 'TradingView Signals Trading' is necessary to trade using TradingView on OctoBot.
  • Avoid enabling both email and webhook alerts on TradingView otherwise alerts will trigger twice your OctoBot.

Back to TradingView configuration

" ) }}
{% else %}
Using the {{OCTOBOT_EXTENSION_PACKAGE_1_NAME}}, your OctoBot can trade using TradingView free email alerts.
{% endif %}
{% if is_community_authenticated and has_open_source_package() %} {% endif %}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/wait_reboot.html ================================================ {% extends "layout.html" %} {% set active_page = "profile" %} {% import 'macros/flash_messages.html' as m_flash_messages %} {% import 'macros/starting_waiter.html' as m_starting_waiter %} {% import 'components/config/exchange_card.html' as m_config_exchange_card %} {% block body %}
{{ m_flash_messages.flash_messages() }}
{{ m_starting_waiter.display_loading_message("Your OctoBot is restarting using the " + current_profile_name + " profile.", "You will be taken to your OctoBot dashboard when it will be ready.", next_url=next_url)}}

{% endblock %} {% block additional_scripts %} {% endblock additional_scripts %} ================================================ FILE: Services/Interfaces/web_interface/templates/welcome.html ================================================ {% extends "layout.html" %} {% set active_page = "home" %} {% set show_nab_bar = false %} {% block body %}

Welcome to your OctoBot !

To quickly get started with OctoBot, the first thing to do is to select a strategy to use.

A strategy is defined in a profile. Each profile can be used either with your real exchange account or using paper trading. Paper trading allows you to experiment a profile risk-free, with a virtual portfolio.

OctoBot cloud
How do you want to first start your OctoBot ? Remember that you can always change your mind later on.

{% endblock %} ================================================ FILE: Services/Interfaces/web_interface/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import threading import asyncio import time import mock import contextlib import octobot_commons.configuration as configuration import octobot_commons.singleton as singleton import octobot_commons.authentication as authentication import octobot_commons.constants as commons_constants import octobot_services.interfaces as interfaces import octobot.community as community try: import octobot.community.supabase_backend.configuration_storage as configuration_storage except ImportError: # todo remove once supabase migration is complete configuration_storage = mock.Mock( ASyncConfigurationStorage=mock.Mock( _save_value_in_config=mock.Mock() ) ) import octobot.automation as automation import octobot.enums import octobot_commons.constants import tentacles.Services.Interfaces.web_interface.controllers.octobot_authentication as octobot_authentication import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface as web_interface PORT = 5555 PASSWORD = "123" MAX_START_TIME = 5 NON_AUTH_ROUTES = ["/api/", "robots.txt"] async def _init_bot(distribution: octobot.enums.OctoBotDistribution): # import here to prevent web interface import issues import octobot.octobot as octobot import octobot.constants as octobot_constants import octobot.producers as producers import octobot_commons.tests as test_config import octobot_tentacles_manager.loaders as loaders import octobot_evaluators.api as evaluators_api import tests.test_utils.config as config # force community CommunityAuthentication reset community.IdentifiersProvider.use_production() singleton.Singleton._instances.pop(authentication.Authenticator, None) singleton.Singleton._instances.pop(community.CommunityAuthentication, None) test_config = test_config.load_test_config(dict_only=False) test_config.config[octobot_commons.constants.CONFIG_DISTRIBUTION] = distribution.value octobot = octobot.OctoBot(test_config) octobot.initialized = True tentacles_config = config.load_test_tentacles_config() loaders.reload_tentacle_by_tentacle_class() octobot.task_manager.async_loop = asyncio.get_event_loop() octobot.task_manager.create_pool_executor() octobot.tentacles_setup_config = tentacles_config octobot.configuration_manager.add_element(octobot_constants.TENTACLES_SETUP_CONFIG_KEY, tentacles_config) octobot.exchange_producer = producers.ExchangeProducer(None, octobot, None, False) octobot.evaluator_producer = producers.EvaluatorProducer(None, octobot) await evaluators_api.initialize_evaluators(octobot.config, tentacles_config) octobot.evaluator_producer.matrix_id = evaluators_api.create_matrix() # Do not edit config file octobot.community_auth.edited_config = None octobot.automation = automation.Automation(octobot.bot_id, tentacles_config) return octobot def _start_web_interface(interface): asyncio.run(interface.start()) # use context manager instead of fixture to prevent pytest threads issues @contextlib.asynccontextmanager async def get_web_interface(require_password: bool, distribution: octobot.enums.OctoBotDistribution): web_interface_instance = None try: with mock.patch.object(configuration_storage.SyncConfigurationStorage, "_save_value_in_config", mock.Mock()): web_interface_instance = web_interface.WebInterface({}) web_interface_instance.port = PORT web_interface_instance.should_open_web_interface = False web_interface_instance.set_requires_password(require_password) web_interface_instance.password_hash = configuration.get_password_hash(PASSWORD) bot = await _init_bot(distribution) interfaces.AbstractInterface.bot_api = bot.octobot_api first_exchange = next(iter(bot.config[commons_constants.CONFIG_EXCHANGES])) with mock.patch.object(web_interface_instance, "_register_on_channels", new=mock.AsyncMock()), \ mock.patch.object(models, "get_current_exchange", mock.Mock(return_value=first_exchange)): threading.Thread(target=_start_web_interface, args=(web_interface_instance,)).start() # ensure web interface had time to start or it can't be stopped at the moment launch_time = time.time() while not web_interface_instance.started and time.time() - launch_time < MAX_START_TIME: await asyncio.sleep(0.3) if not web_interface_instance.started: raise RuntimeError("Web interface did not start in time") yield web_interface_instance finally: if web_interface_instance is not None: await web_interface_instance.stop() async def check_page_no_login_redirect(url, session): COMMUNITY_LOGIN_CONTAINED_PAGE_SUFFIXES = [ "login", "logout", "/profiles_selector", "/community" # redirects ] async with session.get(url) as resp: text = await resp.text() assert "We are sorry, but an unexpected error occurred" not in text, f"{url=}" assert "We are sorry, but this doesn't exist" not in text, f"{url=}" if not (any(url.endswith(suffix)) for suffix in COMMUNITY_LOGIN_CONTAINED_PAGE_SUFFIXES): assert "input type=submit value=Login" not in text, f"{url=}" assert not resp.real_url.name == "login", f"{resp.real_url.name=} != 200 ({url=})" assert resp.status == 200, f"{resp.status=} != 200 ({url=})" async def check_page_login_redirect(url, session): async with session.get(url) as resp: text = await resp.text() assert "We are sorry, but an unexpected error occurred" not in text, f"{url=}" assert "We are sorry, but this doesn't exist" not in text, f"{url=}" if not any(route in url for route in NON_AUTH_ROUTES): assert "input type=submit value=Login" in text, url assert resp.real_url.name == "login", f"{resp.real_url.name=} != 200 ({url=})" assert resp.status == 200, f"{resp.status=} != 200 ({url=})" def get_plugins_routes(web_interface_instance): all_rules = tuple(rule for rule in web_interface_instance.server_instance.url_map.iter_rules()) plugin_routes = [] for plugin in web_interface_instance.registered_plugins: plugin_routes += [ rule.rule for rule in get_plugin_routes(web_interface_instance.server_instance, plugin, all_rules) ] return plugin_routes def get_plugin_routes(app, plugin, all_rules=None): all_rules = all_rules or [rule for rule in app.url_map.iter_rules()] return ( route for route in all_rules if route.rule.startswith(f"{plugin.blueprint.url_prefix}/") ) def _force_validate_on_submit(*_): return True async def login_user_on_session(session): login_data = { "password": PASSWORD, "remember_me": False } with mock.patch.object(octobot_authentication.LoginForm, "validate_on_submit", new=_force_validate_on_submit): async with session.post(f"http://localhost:{PORT}/login", data=login_data) as resp: assert resp.status == 200 def get_all_plugin_rules(app, plugin_class, black_list): plugin_instance = plugin_class.factory() plugin_instance.blueprint_factory() return set(rule.rule for rule in get_plugin_routes(app, plugin_instance) if "GET" in rule.methods and _has_no_empty_params(rule) and rule.rule not in black_list) def _has_no_empty_params(rule): defaults = rule.defaults if rule.defaults is not None else () arguments = rule.arguments if rule.arguments is not None else () return len(defaults) >= len(arguments) ================================================ FILE: Services/Interfaces/web_interface/tests/distribution_tester.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import aiohttp import asyncio import tentacles.Services.Interfaces.web_interface.tests as web_interface_tests import octobot.enums LOCAL_HOST_URL = "http://localhost:" class AbstractDistributionTester: VERBOSE = False # Set true to print tested urls DISTRIBUTION: octobot.enums.OctoBotDistribution = None # backlist endpoints expecting additional data URL_BLACK_LIST = [] DOTTED_URLS = [] async def test_browse_all_pages_no_required_password(self): await self._inner_test_browse_all_pages_no_required_password([]) async def _inner_test_browse_all_pages_no_required_password(self, black_list: list[str]): async with web_interface_tests.get_web_interface(False, self.DISTRIBUTION) as web_interface_instance: async with aiohttp.ClientSession() as session: await asyncio.gather(*[ web_interface_tests.check_page_no_login_redirect(self._get_rule_url(rule), session) for rule in self._get_all_native_rules(web_interface_instance, black_list=black_list) ]) async def test_browse_all_pages_required_password_without_login(self): await self._inner_test_browse_all_pages_required_password_without_login([]) async def _inner_test_browse_all_pages_required_password_without_login(self, black_list: list[str]): async with web_interface_tests.get_web_interface(True, self.DISTRIBUTION) as web_interface_instance: async with aiohttp.ClientSession() as session: await asyncio.gather(*[ web_interface_tests.check_page_login_redirect(self._get_rule_url(rule), session) for rule in self._get_all_native_rules(web_interface_instance, black_list=black_list) ]) async def test_browse_all_pages_required_password_with_login(self): await self.inner_test_browse_all_pages_required_password_with_login([], []) async def inner_test_browse_all_pages_required_password_with_login( self, auth_black_list: list[str], unauth_black_list: list[str] ): async with web_interface_tests.get_web_interface(True, self.DISTRIBUTION) as web_interface_instance: async with aiohttp.ClientSession() as session: await web_interface_tests.login_user_on_session(session) # correctly display pages: session is logged in await asyncio.gather(*[ web_interface_tests.check_page_no_login_redirect(self._get_rule_url(rule), session) for rule in self._get_all_native_rules(web_interface_instance, black_list=auth_black_list) ]) async with aiohttp.ClientSession() as unauthenticated_session: # redirect to login page: session is not logged in await asyncio.gather(*[ web_interface_tests.check_page_login_redirect(self._get_rule_url(rule), unauthenticated_session) for rule in self._get_all_native_rules(web_interface_instance, black_list=unauth_black_list) ]) async def test_logout(self): async with web_interface_tests.get_web_interface(True, self.DISTRIBUTION): async with aiohttp.ClientSession() as session: await web_interface_tests.login_user_on_session(session) await web_interface_tests.check_page_no_login_redirect( f"{LOCAL_HOST_URL}{web_interface_tests.PORT}/", session ) await web_interface_tests.check_page_login_redirect( f"{LOCAL_HOST_URL}{web_interface_tests.PORT}/logout", session) await web_interface_tests.check_page_login_redirect( f"{LOCAL_HOST_URL}{web_interface_tests.PORT}/", session ) def _get_all_native_rules(self, web_interface_instance, black_list=None): if black_list is None: black_list = [] full_back_list = self.URL_BLACK_LIST + black_list + web_interface_tests.get_plugins_routes(web_interface_instance) rules = set( rule.rule for rule in web_interface_instance.server_instance.url_map.iter_rules() if "GET" in rule.methods and _has_no_empty_params(rule) and rule.rule not in full_back_list ) if self.VERBOSE: print(f"{self.__class__.__name__} Tested {len(rules)} rules: {rules}") return rules def _get_rule_url(self, rule: str): if rule in self.DOTTED_URLS: path = rule else: path = rule.replace('.', '/') return f"{LOCAL_HOST_URL}{web_interface_tests.PORT}{path}" def _has_no_empty_params(rule): defaults = rule.defaults if rule.defaults is not None else () arguments = rule.arguments if rule.arguments is not None else () return len(defaults) >= len(arguments) ================================================ FILE: Services/Interfaces/web_interface/tests/distributions/__init__.py ================================================ ================================================ FILE: Services/Interfaces/web_interface/tests/distributions/test_default.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import tentacles.Services.Interfaces.web_interface.tests.distribution_tester as distribution_tester import octobot.enums # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio _COMMUNITY_ACCOUNT_REQUIRED_PATHS = [ "/advanced/tentacles", "/advanced/tentacle_packages", "/api/tradingview_confirm_email_content", ] class TestDefaultDistribution(distribution_tester.AbstractDistributionTester): DISTRIBUTION = octobot.enums.OctoBotDistribution.DEFAULT # backlist endpoints expecting additional data URL_BLACK_LIST = [ "/symbol_market_status", "/tentacle_media", "/watched_symbols", "/export_logs", "/api/first_exchange_details" ] DOTTED_URLS = ["/robots.txt"] VERBOSE = False async def test_browse_all_pages_no_required_password(self): await self._inner_test_browse_all_pages_no_required_password(_COMMUNITY_ACCOUNT_REQUIRED_PATHS) async def test_browse_all_pages_required_password_without_login(self): await self._inner_test_browse_all_pages_required_password_without_login([]) async def test_browse_all_pages_required_password_with_login(self): await self.inner_test_browse_all_pages_required_password_with_login(_COMMUNITY_ACCOUNT_REQUIRED_PATHS, []) ================================================ FILE: Services/Interfaces/web_interface/tests/distributions/test_market_making.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import tentacles.Services.Interfaces.web_interface.tests.distribution_tester as distribution_tester import octobot.enums # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio _COMMUNITY_ACCOUNT_REQUIRED_PATHS = [ "/api/tradingview_confirm_email_content", ] class TestMarketMakingDistributionPlugin(distribution_tester.AbstractDistributionTester): DISTRIBUTION = octobot.enums.OctoBotDistribution.MARKET_MAKING # backlist endpoints expecting additional data URL_BLACK_LIST = [ "/tentacle_media", "/export_logs", "/api/first_exchange_details" ] DOTTED_URLS = ["/robots.txt"] VERBOSE = False async def test_browse_all_pages_no_required_password(self): await self._inner_test_browse_all_pages_no_required_password(_COMMUNITY_ACCOUNT_REQUIRED_PATHS) async def test_browse_all_pages_required_password_without_login(self): await self._inner_test_browse_all_pages_required_password_without_login([]) async def test_browse_all_pages_required_password_with_login(self): await self.inner_test_browse_all_pages_required_password_with_login( _COMMUNITY_ACCOUNT_REQUIRED_PATHS, [] ) ================================================ FILE: Services/Interfaces/web_interface/tests/plugin_tester.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import aiohttp import asyncio import tentacles.Services.Interfaces.web_interface.tests as web_interface_tests import octobot.enums class AbstractPluginTester: DISTRIBUTION: octobot.enums.OctoBotDistribution = octobot.enums.OctoBotDistribution.DEFAULT VERBOSE = False # Set true to print tested urls PLUGIN = None URL_BLACK_LIST = [] async def test_browse_all_pages_no_required_password(self): async with web_interface_tests.get_web_interface(False, self.DISTRIBUTION) as web_interface_instance: async with aiohttp.ClientSession() as session: await asyncio.gather( *[web_interface_tests.check_page_no_login_redirect( f"http://localhost:{web_interface_tests.PORT}{rule.replace('.', '/')}", session) for rule in self._get_rules(web_interface_instance)]) async def test_browse_all_pages_required_password_without_login(self): async with web_interface_tests.get_web_interface(True, self.DISTRIBUTION) as web_interface_instance: async with aiohttp.ClientSession() as session: await asyncio.gather( *[web_interface_tests.check_page_login_redirect( f"http://localhost:{web_interface_tests.PORT}{rule.replace('.', '/')}", session) for rule in self._get_rules(web_interface_instance)]) async def test_browse_all_pages_required_password_with_login(self): async with web_interface_tests.get_web_interface(True, self.DISTRIBUTION) as web_interface_instance: async with aiohttp.ClientSession() as session: await web_interface_tests.login_user_on_session(session) # correctly display pages: session is logged in await asyncio.gather( *[web_interface_tests.check_page_no_login_redirect( f"http://localhost:{web_interface_tests.PORT}{rule.replace('.', '/')}", session) for rule in self._get_rules(web_interface_instance)]) async with aiohttp.ClientSession() as unauthenticated_session: # redirect to login page: session is not logged in await asyncio.gather( *[web_interface_tests.check_page_login_redirect( f"http://localhost:{web_interface_tests.PORT}{rule.replace('.', '/')}", unauthenticated_session) for rule in self._get_rules(web_interface_instance)]) def _get_rules(self, web_interface_instance): rules = web_interface_tests.get_all_plugin_rules( web_interface_instance.server_instance, self.PLUGIN, self.URL_BLACK_LIST ) if self.VERBOSE: print(f"{self.__class__.__name__} Tested {len(rules)} rules: {rules}") return rules ================================================ FILE: Services/Interfaces/web_interface/util/__init__.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from tentacles.Services.Interfaces.web_interface.util import flask_util from tentacles.Services.Interfaces.web_interface.util.flask_util import ( get_rest_reply, ) from tentacles.Services.Interfaces.web_interface.util import browser_util from tentacles.Services.Interfaces.web_interface.util.browser_util import ( open_in_background_browser, ) __all__ = [ "get_rest_reply", "open_in_background_browser", ] ================================================ FILE: Services/Interfaces/web_interface/util/browser_util.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import webbrowser def open_in_background_browser(url): """ Uses webbrowser.open(url) but skips non-background browsers as they are blocking the current process, we don't want that. Warning: should be called before any other call to webbrowser otherwise default browser discovery (including non-background browsers) will be processed by webbrowser """ # env var used to identify console browsers, which are not background browsers term_var = "TERM" prev_val = None if term_var in os.environ: prev_val = os.environ[term_var] # unsetting it skips console browser discovery os.environ[term_var] = "" try: webbrowser.open(url) finally: if prev_val is not None: # restore env variable os.environ[term_var] = prev_val ================================================ FILE: Services/Interfaces/web_interface/util/flask_util.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask def get_rest_reply(json_message, code=200, content_type="application/json"): resp = flask.make_response(json_message, code) resp.headers['Content-Type'] = content_type return resp ================================================ FILE: Services/Interfaces/web_interface/web.py ================================================ # Drakkar-Software OctoBot-Interfaces # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import socket import time import flask import flask_cors import flask_socketio from flask_compress import Compress from flask_caching import Cache import octobot_commons.logging as bot_logging import octobot_services.constants as services_constants import octobot_services.interfaces as services_interfaces import octobot_services.interfaces.util as interfaces_util import octobot_trading.api as trading_api import octobot.configuration_manager as configuration_manager import octobot.enums import tentacles.Services.Interfaces.web_interface.constants as constants import tentacles.Services.Interfaces.web_interface.login as login import tentacles.Services.Interfaces.web_interface.security as security import tentacles.Services.Interfaces.web_interface.websockets as websockets import tentacles.Services.Interfaces.web_interface.plugins as web_interface_plugins import tentacles.Services.Interfaces.web_interface.flask_util as flask_util import tentacles.Services.Interfaces.web_interface.util as web_interface_util import tentacles.Services.Interfaces.web_interface as web_interface_root import tentacles.Services.Interfaces.web_interface.controllers import tentacles.Services.Interfaces.web_interface.advanced_controllers import tentacles.Services.Interfaces.web_interface.api import tentacles.Services.Services_bases as Service_bases import octobot_tentacles_manager.api class WebInterface(services_interfaces.AbstractWebInterface): REQUIRED_SERVICES = [Service_bases.WebService] COLOR_MODE = "color_mode" ANNOUNCEMENTS = "announcements" DISPLAY_TIME_FRAME = "display_time_frame" DISPLAY_ORDERS = "display_orders" WATCHED_SYMBOLS = "watched_symbols" tools = { constants.BOT_TOOLS_BACKTESTING: None, constants.BOT_TOOLS_BACKTESTING_SOURCE: None, constants.BOT_TOOLS_STRATEGY_OPTIMIZER: None, constants.BOT_TOOLS_DATA_COLLECTOR: None, constants.BOT_PREPARING_BACKTESTING: False, } def __init__(self, config): super().__init__(config) self.logger = self.get_logger() self.server_instance = None self.host = None self.port = None self.websocket_instance = None self.web_login_manger = None self.requires_password = False self.password_hash = "" self.dev_mode = False self.started = False self.registered_plugins = [] self._init_web_settings() self.local_config = None if interfaces_util.get_bot_api() is None: # should not happen in non-test environment self.logger.error( f"interfaces_util.get_bot_api() is not available at {self.get_name()} constructor" ) else: self.reload_config() async def register_new_exchange_impl(self, exchange_id): if exchange_id not in self.registered_exchanges_ids: await self._register_on_channels(exchange_id) def reload_config(self, tentacles_setup_config=None): self.local_config = octobot_tentacles_manager.api.get_tentacle_config( tentacles_setup_config or interfaces_util.get_edited_tentacles_config(), self.__class__ ) def _init_web_settings(self): try: self.host = os.getenv(services_constants.ENV_WEB_ADDRESS, self.config[services_constants.CONFIG_CATEGORY_SERVICES] [services_constants.CONFIG_WEB][services_constants.CONFIG_WEB_IP]) except KeyError: self.host = os.getenv(services_constants.ENV_WEB_ADDRESS, services_constants.DEFAULT_SERVER_IP) try: self.port = int(os.getenv(services_constants.ENV_WEB_PORT, self.config[services_constants.CONFIG_CATEGORY_SERVICES] [services_constants.CONFIG_WEB][services_constants.CONFIG_WEB_PORT])) except KeyError: self.port = int(os.getenv(services_constants.ENV_WEB_PORT, services_constants.DEFAULT_SERVER_PORT)) try: self.requires_password = \ self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEB] \ [services_constants.CONFIG_WEB_REQUIRES_PASSWORD] except KeyError: pass try: self.password_hash = self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ [services_constants.CONFIG_WEB][services_constants.CONFIG_WEB_PASSWORD] except KeyError: pass try: env_value = os.getenv(services_constants.ENV_AUTO_OPEN_IN_WEB_BROWSER, None) if env_value is None: self.should_open_web_interface = self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ [services_constants.CONFIG_WEB][services_constants.CONFIG_AUTO_OPEN_IN_WEB_BROWSER] else: self.should_open_web_interface = env_value.lower() == "true" except KeyError: self.should_open_web_interface = True self.dev_mode = False if interfaces_util.get_bot_api() is None else\ interfaces_util.get_edited_config(dict_only=False).dev_mode_enabled() @staticmethod async def _web_trades_callback(exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, trade, old_trade): web_interface_root.send_new_trade( trade, exchange_id, symbol ) @staticmethod async def _web_orders_callback(exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, order, update_type, is_from_bot): web_interface_root.send_order_update(order, exchange_id, symbol) @staticmethod async def _web_ohlcv_empty_callback( exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, candle ): pass async def _register_on_channels(self, exchange_id): try: if trading_api.is_exchange_trading(trading_api.get_exchange_manager_from_exchange_id(exchange_id)): await trading_api.subscribe_to_trades_channel(self._web_trades_callback, exchange_id) await trading_api.subscribe_to_order_channel(self._web_orders_callback, exchange_id) await trading_api.subscribe_to_ohlcv_channel(self._web_ohlcv_empty_callback, exchange_id) except ImportError: self.logger.error("Watching trade channels requires OctoBot-Trading package installed") def init_flask_plugins_and_config(self, server_instance): # Only setup flask plugins once per flask app (can't call flask setup methods after the 1st request # has been received). # Override system configuration content types flask_util.init_content_types() self.server_instance.json = flask_util.FloatDecimalJSONProvider(self.server_instance) # Set CORS policy if flask_util.get_user_defined_cors_allowed_origins() != "*": # never allow "*" as allowed origin, prefer not setting it if user did not specifically set origins flask_cors.CORS(self.server_instance, origins=flask_util.get_user_defined_cors_allowed_origins()) self.server_instance.config['SEND_FILE_MAX_AGE_DEFAULT'] = 604800 if self.dev_mode: server_instance.config['TEMPLATES_AUTO_RELOAD'] = True else: cache = Cache(config={"CACHE_TYPE": "SimpleCache"}) cache.init_app(server_instance) Compress(server_instance) flask_util.register_context_processor(self) flask_util.register_template_filters(server_instance) # register session secret key server_instance.secret_key = flask_util.BrowsingDataProvider.instance().get_or_create_session_secret_key() self._handle_login(server_instance) security.register_responses_extra_header(server_instance, True) def _handle_login(self, server_instance): self.web_login_manger = login.WebLoginManager(server_instance, self.password_hash) login.set_is_login_required(self.requires_password) def set_requires_password(self, requires_password): self.requires_password = requires_password login.set_is_login_required(requires_password) def _register_routes(self, server_instance, distribution: octobot.enums.OctoBotDistribution): tentacles.Services.Interfaces.web_interface.controllers.register(server_instance, distribution) server_instance.register_blueprint( tentacles.Services.Interfaces.web_interface.api.register(distribution) ) server_instance.register_blueprint( tentacles.Services.Interfaces.web_interface.advanced_controllers.register(distribution) ) def _prepare_websocket(self, server_instance): # handles all namespaces without an explicit error handler websocket_instance = flask_socketio.SocketIO( server_instance, async_mode="gevent", cors_allowed_origins=flask_util.get_user_defined_cors_allowed_origins() ) @websocket_instance.on_error_default def default_error_handler(e): self.logger.exception(e, True, f"Error with websocket: {e}") for namespace in websockets.namespaces: websocket_instance.on_namespace(namespace) bot_logging.register_error_notifier(web_interface_root.send_general_notifications) return websocket_instance async def _async_run(self) -> bool: # wait bot is ready while not self.is_bot_ready(): time.sleep(0.05) try: self.server_instance = flask.Flask(__name__) distribution = configuration_manager.get_distribution(interfaces_util.get_edited_config()) self._register_routes(self.server_instance, distribution) if distribution is octobot.enums.OctoBotDistribution.DEFAULT: # for now, plugins are only available on default distribution self.registered_plugins = web_interface_plugins.register_all_plugins( self.server_instance, self.registered_plugins ) web_interface_root.update_registered_plugins(self.registered_plugins) self.init_flask_plugins_and_config(self.server_instance) self.websocket_instance = self._prepare_websocket(self.server_instance) if self.should_open_web_interface: self._open_web_interface_on_browser() self.started = True self.websocket_instance.run(self.server_instance, host=self.host, port=self.port, log_output=False, debug=False) return True except Exception as e: self.logger.exception(e, False, f"Fail to start web interface : {e}") finally: self.logger.debug("Web interface thread stopped") return False def _open_web_interface_on_browser(self): try: web_interface_util.open_in_background_browser( f"http://{socket.gethostbyname(socket.gethostname())}:{self.port}" ) except Exception as err: self.logger.warning(f"Impossible to open automatically web interface: {err} ({err.__class__.__name__})") async def _inner_start(self): return self.threaded_start() async def stop(self): if self.websocket_instance is not None: try: self.logger.debug("Stopping web interface") self.websocket_instance.stop() self.logger.debug("Stopped web interface") except Exception as e: self.logger.exception(e, False, f"Error when stopping web interface : {e}") ================================================ FILE: Services/Interfaces/web_interface/websockets/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. namespaces = [] from tentacles.Services.Interfaces.web_interface.websockets import abstract_websocket_namespace_notifier from tentacles.Services.Interfaces.web_interface.websockets.abstract_websocket_namespace_notifier import ( AbstractWebSocketNamespaceNotifier, websocket_with_login_required_when_activated, ) from tentacles.Services.Interfaces.web_interface.websockets import data_collector from tentacles.Services.Interfaces.web_interface.websockets import backtesting from tentacles.Services.Interfaces.web_interface.websockets import dashboard from tentacles.Services.Interfaces.web_interface.websockets import notifications from tentacles.Services.Interfaces.web_interface.websockets import strategy_optimizer from tentacles.Services.Interfaces.web_interface.websockets.data_collector import ( DataCollectorNamespace, ) from tentacles.Services.Interfaces.web_interface.websockets.backtesting import ( BacktestingNamespace, ) from tentacles.Services.Interfaces.web_interface.websockets.dashboard import ( DashboardNamespace, ) from tentacles.Services.Interfaces.web_interface.websockets.notifications import ( NotificationsNamespace, ) from tentacles.Services.Interfaces.web_interface.websockets.strategy_optimizer import ( StrategyOptimizerNamespace, ) __all__ = [ "AbstractWebSocketNamespaceNotifier", "websocket_with_login_required_when_activated", "BacktestingNamespace", "DataCollectorNamespace", "DashboardNamespace", "NotificationsNamespace", "StrategyOptimizerNamespace", ] ================================================ FILE: Services/Interfaces/web_interface/websockets/abstract_websocket_namespace_notifier.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import functools import flask_login import flask_socketio import octobot_commons.logging as bot_logger import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.login as login class AbstractWebSocketNamespaceNotifier(flask_socketio.Namespace, web_interface.Notifier): def __init__(self, namespace=None): super(flask_socketio.Namespace, self).__init__(namespace) self.logger = bot_logger.get_logger(self.__class__.__name__) # constructor can be called in global project import, in this case manually enable logger self.logger.disable(False) self.clients_count = 0 def all_clients_send_notifications(self, **kwargs) -> bool: raise NotImplementedError("all_clients_send_notifications is not implemented") def on_connect(self): self.clients_count += 1 def on_disconnect(self, reason): # will be called after some time (requires timeout) self.clients_count -= 1 def _has_clients(self): return self.clients_count > 0 def websocket_with_login_required_when_activated(func): @functools.wraps(func) def wrapped(self, *args, **kwargs): # Use == because of the flask proxy (this is not a simple python None value) if login.is_login_required() and \ (flask_login.current_user is None or not flask_login.current_user.is_authenticated): flask_socketio.disconnect(self) else: return func(self, *args, **kwargs) return wrapped ================================================ FILE: Services/Interfaces/web_interface/websockets/backtesting.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask_socketio import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.websockets as websockets class BacktestingNamespace(websockets.AbstractWebSocketNamespaceNotifier): @staticmethod def _get_backtesting_status(): backtesting_status, progress, errors = models.get_backtesting_status() return {"status": backtesting_status, "progress": progress, "errors": errors} @websockets.websocket_with_login_required_when_activated def on_backtesting_status(self): flask_socketio.emit("backtesting_status", self._get_backtesting_status()) def all_clients_send_notifications(self, **kwargs) -> bool: if self._has_clients(): try: self.socketio.emit("backtesting_status", self._get_backtesting_status(), namespace=self.namespace) return True except Exception as e: self.logger.exception(e, True, f"Error when sending backtesting_status: {e}") return False @websockets.websocket_with_login_required_when_activated def on_connect(self): super().on_connect() self.on_backtesting_status() notifier = BacktestingNamespace('/backtesting') web_interface.register_notifier(web_interface.BACKTESTING_NOTIFICATION_KEY, notifier) websockets.namespaces.append(notifier) ================================================ FILE: Services/Interfaces/web_interface/websockets/dashboard.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask_socketio import octobot_commons.pretty_printer as pretty_printer import octobot_trading.enums as trading_enums import octobot_services.interfaces as services_interfaces import octobot_trading.api as octobot_trading_api import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.websockets as websockets class DashboardNamespace(websockets.AbstractWebSocketNamespaceNotifier): @staticmethod def _get_profitability(): profitability_digits = None has_real_trader, has_simulated_trader, \ real_global_profitability, simulated_global_profitability, \ real_percent_profitability, simulated_percent_profitability, \ real_no_trade_profitability, simulated_no_trade_profitability, \ market_average_profitability = services_interfaces.get_global_profitability() profitability_data = { "market_average_profitability": pretty_printer.round_with_decimal_count(market_average_profitability, profitability_digits) } if has_real_trader: profitability_data["bot_real_profitability"] = \ pretty_printer.round_with_decimal_count(real_percent_profitability, profitability_digits) profitability_data["bot_real_flat_profitability"] = \ pretty_printer.round_with_decimal_count(real_global_profitability, profitability_digits) profitability_data["real_no_trade_profitability"] = \ pretty_printer.round_with_decimal_count(real_no_trade_profitability, profitability_digits) if has_simulated_trader: profitability_data["bot_simulated_profitability"] = \ pretty_printer.round_with_decimal_count(simulated_percent_profitability, profitability_digits) profitability_data["bot_simulated_flat_profitability"] = \ pretty_printer.round_with_decimal_count(simulated_global_profitability, profitability_digits) profitability_data["simulated_no_trade_profitability"] = \ pretty_printer.round_with_decimal_count(simulated_no_trade_profitability, profitability_digits) return profitability_data @staticmethod def _format_new_data(exchange_id=None, trades=None, order=None, symbol=None): exchange_manager = octobot_trading_api.get_exchange_manager_from_exchange_id(exchange_id) return { "trades": models.format_trades(trades), "orders": models.format_orders(octobot_trading_api.get_open_orders(exchange_manager, symbol=symbol), 0), "simulated": octobot_trading_api.is_trader_simulated(exchange_manager), "symbol": symbol, "exchange_id": exchange_id } @websockets.websocket_with_login_required_when_activated def on_profitability(self): flask_socketio.emit("profitability", self._get_profitability()) def all_clients_send_notifications(self, **kwargs) -> bool: if self._has_clients(): try: self.socketio.emit("new_data", { "data": self._format_new_data(**kwargs) }, namespace=self.namespace) return True except Exception as e: self.logger.exception(e, True, f"Error when sending web notification: {e}") return False @websockets.websocket_with_login_required_when_activated def on_candle_graph_update(self, data): try: flask_socketio.emit("candle_graph_update_data", { "request": data, "data": models.get_currency_price_graph_update(data["exchange_id"], models.get_value_from_dict_or_string(data["symbol"]), data["time_frame"], backtesting=False, minimal_candles=True, ignore_trades=True, ignore_orders=not models.get_display_orders()) }) except KeyError: flask_socketio.emit("error", "missing exchange manager") @websockets.websocket_with_login_required_when_activated def on_connect(self): super().on_connect() self.on_profitability() notifier = DashboardNamespace('/dashboard') web_interface.register_notifier(web_interface.DASHBOARD_NOTIFICATION_KEY, notifier) websockets.namespaces.append(notifier) ================================================ FILE: Services/Interfaces/web_interface/websockets/data_collector.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask_socketio import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.websockets as websockets class DataCollectorNamespace(websockets.AbstractWebSocketNamespaceNotifier): @staticmethod def _get_data_collector_status(): data_collector_status, progress = models.get_data_collector_status() return {"status": data_collector_status, "progress": progress} @websockets.websocket_with_login_required_when_activated def on_data_collector_status(self): flask_socketio.emit("data_collector_status", self._get_data_collector_status()) def all_clients_send_notifications(self, **kwargs) -> bool: if self._has_clients(): try: self.socketio.emit("data_collector_status", self._get_data_collector_status(), namespace=self.namespace) return True except Exception as e: self.logger.exception(e, True, f"Error when sending backtesting_status: {e}") return False @websockets.websocket_with_login_required_when_activated def on_connect(self): super().on_connect() self.on_data_collector_status() notifier = DataCollectorNamespace('/data_collector') web_interface.register_notifier(web_interface.DATA_COLLECTOR_NOTIFICATION_KEY, notifier) websockets.namespaces.append(notifier) ================================================ FILE: Services/Interfaces/web_interface/websockets/notifications.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import copy import flask_socketio import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.websockets as websockets class NotificationsNamespace(websockets.AbstractWebSocketNamespaceNotifier): @staticmethod def _get_update_data(): return { "notifications": web_interface.get_notifications(), "errors_count": web_interface.get_errors_count() } def _client_context_send_notifications(self): flask_socketio.emit("update", self._get_update_data()) def all_clients_send_notifications(self, **kwargs) -> bool: if self._has_clients(): try: self.socketio.emit("update", self._get_update_data(), namespace=self.namespace) return True except Exception as e: self.logger.exception(e, True, f"Error when sending web notification: {e}") return False @websockets.websocket_with_login_required_when_activated def on_connect(self): super().on_connect() self._client_context_send_notifications() web_interface.flush_notifications() notifier = NotificationsNamespace('/notifications') web_interface.register_notifier(web_interface.GENERAL_NOTIFICATION_KEY, notifier) websockets.namespaces.append(notifier) ================================================ FILE: Services/Interfaces/web_interface/websockets/strategy_optimizer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import flask_socketio import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Interfaces.web_interface.models as models import tentacles.Services.Interfaces.web_interface.websockets as websockets class StrategyOptimizerNamespace(websockets.AbstractWebSocketNamespaceNotifier): @staticmethod def _get_strategy_optimizer_status(): optimizer_status, progress, overall_progress, remaining_time, errors = models.get_optimizer_status() return { "status": optimizer_status, "progress": progress, "overall_progress": overall_progress, "remaining_time": remaining_time, "errors": errors } @websockets.websocket_with_login_required_when_activated def on_strategy_optimizer_status(self): flask_socketio.emit("strategy_optimizer_status", self._get_strategy_optimizer_status()) def all_clients_send_notifications(self, **kwargs) -> bool: if self._has_clients(): try: self.socketio.emit("strategy_optimizer_status", self._get_strategy_optimizer_status(), namespace=self.namespace) return True except Exception as e: self.logger.exception(e, True, f"Error when sending strategy_optimizer_status: {e}") return False @websockets.websocket_with_login_required_when_activated def on_connect(self): super().on_connect() self.on_strategy_optimizer_status() notifier = StrategyOptimizerNamespace('/strategy_optimizer') web_interface.register_notifier(web_interface.STRATEGY_OPTIMIZER_NOTIFICATION_KEY, notifier) websockets.namespaces.append(notifier) ================================================ FILE: Services/Notifiers/telegram_notifier/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .telegram import TelegramNotifier ================================================ FILE: Services/Notifiers/telegram_notifier/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramNotifier"], "tentacles-requirements": ["telegram_service"] } ================================================ FILE: Services/Notifiers/telegram_notifier/telegram.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.enums as commons_enums import octobot_services.notification as notification import octobot_services.notifier as notifier import tentacles.Services.Services_bases as Services_bases class TelegramNotifier(notifier.AbstractNotifier): REQUIRED_SERVICES = [Services_bases.TelegramService] NOTIFICATION_TYPE_KEY = "telegram" USE_MAIN_LOOP = True async def _handle_notification(self, notification: notification.Notification): self.logger.debug(f"sending notification: {notification}") text, use_markdown = self._get_message_text(notification) await self._send_message(notification, text, use_markdown) async def _send_message(self, notification, text, use_markdown): try: previous_message_id = notification.linked_notification.metadata[self.NOTIFICATION_TYPE_KEY].message_id \ if notification.linked_notification and \ self.NOTIFICATION_TYPE_KEY in notification.linked_notification.metadata else None except (KeyError, AttributeError): previous_message_id = None sent_message = await self.services[0].send_message(text, markdown=use_markdown, reply_to_message_id=previous_message_id) if sent_message is None and previous_message_id is not None: # failed to reply, try regular message self.logger.warning(f"Failed to reply to message with id {previous_message_id}, sending regular message.") sent_message = await self.services[0].send_message(text, markdown=use_markdown, reply_to_message_id=None) notification.metadata[self.NOTIFICATION_TYPE_KEY] = sent_message @staticmethod def _get_message_text(notification): title = notification.title text = notification.markdown_text if notification.markdown_text else notification.text if notification.markdown_format not in (commons_enums.MarkdownFormat.NONE, commons_enums.MarkdownFormat.IGNORE): text = f"{notification.markdown_format.value}{text}{notification.markdown_format.value}" if title: title = f"{commons_enums.MarkdownFormat.CODE.value}{title}{commons_enums.MarkdownFormat.CODE.value}" text = f"{title}\n{text}" use_markdown = notification.markdown_format is not commons_enums.MarkdownFormat.NONE return text, use_markdown ================================================ FILE: Services/Notifiers/twitter_notifier/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .twitter import TwitterNotifier ================================================ FILE: Services/Notifiers/twitter_notifier/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TwitterNotifier"], "tentacles-requirements": ["twitter_service"] } ================================================ FILE: Services/Notifiers/twitter_notifier/twitter.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_services.notification as notification import octobot_services.notifier as notifier import tentacles.Services.Services_bases as Services_bases # disable inheritance to disable tentacle visibility. Disabled as starting from feb 9 2023, API is now paid only # class TwitterNotifier(notifier.AbstractNotifier): class TwitterNotifier: REQUIRED_SERVICES = [Services_bases.TwitterService] NOTIFICATION_TYPE_KEY = "twitter" async def _handle_notification(self, notification: notification.Notification): self.logger.debug(f"sending notification: {notification}") if notification.linked_notification is None: result = await self._send_regular_tweet(notification) else: result = await self._send_tweet_reply(notification) if result is None: self.logger.error(f"Tweet is not sent, notification: {notification}") else: self.logger.info("Tweet sent") async def _send_regular_tweet(self, notification): result = await self.services[0].post(self._get_tweet_text(notification), True) notification.metadata[self.NOTIFICATION_TYPE_KEY] = result return result async def _send_tweet_reply(self, notification): try: previous_tweet_id = notification.linked_notification.metadata[self.NOTIFICATION_TYPE_KEY].id result = await self.services[0].respond(previous_tweet_id, self._get_tweet_text(notification), True) notification.metadata[self.NOTIFICATION_TYPE_KEY] = result return result except (KeyError, AttributeError): return await self._send_regular_tweet(notification) @staticmethod def _get_tweet_text(notification): return f"{notification.title}\n{notification.text}" if notification.title else notification.text ================================================ FILE: Services/Notifiers/web_notifier/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .web import WebNotifier ================================================ FILE: Services/Notifiers/web_notifier/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["WebNotifier"], "tentacles-requirements": ["web_service"] } ================================================ FILE: Services/Notifiers/web_notifier/web.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_services.notification as services_notification import octobot_services.notifier as notifier import tentacles.Services.Interfaces.web_interface as web_interface import tentacles.Services.Services_bases as Services_bases class WebNotifier(notifier.AbstractNotifier): REQUIRED_SERVICES = [Services_bases.WebService] NOTIFICATION_TYPE_KEY = "web" async def _handle_notification(self, notification: services_notification.Notification): await web_interface.add_notification(notification.level, notification.title, notification.text.replace("\n", "
"), sound=notification.sound.value) ================================================ FILE: Services/Services_bases/google_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .google import GoogleService ================================================ FILE: Services/Services_bases/google_service/google.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_services.constants as services_constants import octobot_services.services as services class GoogleService(services.AbstractService): @staticmethod def is_setup_correctly(config): return True @staticmethod def get_is_enabled(config): return True def has_required_configuration(self): return True def get_endpoint(self) -> None: return None def get_type(self) -> None: return services_constants.CONFIG_GOOGLE async def prepare(self) -> None: pass def get_successful_startup_message(self): return "", True ================================================ FILE: Services/Services_bases/google_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GoogleService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/gpt_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .gpt import GPTService ================================================ FILE: Services/Services_bases/gpt_service/gpt.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import os import typing import uuid import openai import logging import datetime import octobot_services.constants as services_constants import octobot_services.services as services import octobot_services.errors as errors import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.logging as commons_logging import octobot_commons.time_frame_manager as time_frame_manager import octobot_commons.authentication as authentication import octobot_commons.tree as tree import octobot_commons.configuration.fields_utils as fields_utils import octobot.constants as constants import octobot.community as community NO_SYSTEM_PROMPT_MODELS = [ "o1-mini", ] MINIMAL_PARAMS_SERIES_MODELS = [ "o", # the whole o-series does not support temperature parameter ] MINIMAL_PARAMS_MODELS = [ "gpt-5", # does not support temperature parameter ] SYSTEM = "system" USER = "user" class GPTService(services.AbstractService): BACKTESTING_ENABLED = True DEFAULT_MODEL = "gpt-3.5-turbo" NO_TOKEN_LIMIT_VALUE = -1 def get_fields_description(self): if self._env_secret_key is None: return { services_constants.CONIG_OPENAI_SECRET_KEY: "Your openai API secret key", services_constants.CONIG_LLM_CUSTOM_BASE_URL: ( "Custom LLM base url to use. Leave empty to use openai.com. For Ollama models, " "add /v1 to the url (such as: http://localhost:11434/v1)" ), } return {} def get_default_value(self): if self._env_secret_key is None: return { services_constants.CONIG_OPENAI_SECRET_KEY: "", services_constants.CONIG_LLM_CUSTOM_BASE_URL: "", } return {} def __init__(self): super().__init__() logging.getLogger("openai").setLevel(logging.WARNING) self._env_secret_key: str = os.getenv(services_constants.ENV_OPENAI_SECRET_KEY, None) or None self.model: str = os.getenv(services_constants.ENV_GPT_MODEL, self.DEFAULT_MODEL) self.stored_signals: tree.BaseTree = tree.BaseTree() self.models: list[str] = [] self._env_daily_token_limit: int = int(os.getenv( services_constants.ENV_GPT_DAILY_TOKENS_LIMIT, self.NO_TOKEN_LIMIT_VALUE) ) self._daily_tokens_limit: int = self._env_daily_token_limit self.consumed_daily_tokens: int = 1 self.last_consumed_token_date: datetime.date = None @staticmethod def create_message(role, content, model: str = None): if role == SYSTEM and model in NO_SYSTEM_PROMPT_MODELS: commons_logging.get_logger(GPTService.__name__).debug( f"Overriding prompt to use {USER} instead of {SYSTEM} for {model}" ) return {"role": USER, "content": content} return {"role": role, "content": content} async def get_chat_completion( self, messages, model=None, max_tokens=3000, n=1, stop=None, temperature=0.5, exchange: str = None, symbol: str = None, time_frame: str = None, version: str = None, candle_open_time: float = None, use_stored_signals: bool = False, ) -> str: if use_stored_signals: return self._get_signal_from_stored_signals(exchange, symbol, time_frame, version, candle_open_time) if self.use_stored_signals_only(): signal = await self._fetch_signal_from_stored_signals(exchange, symbol, time_frame, version, candle_open_time) if not signal: # should not happen self.logger.error( f"Missing ChatGPT signal from stored signals on {symbol} {time_frame} " f"for timestamp: {candle_open_time} with version: {version}" ) return signal return await self._get_signal_from_gpt(messages, model, max_tokens, n, stop, temperature) def _get_client(self) -> openai.AsyncOpenAI: return openai.AsyncOpenAI( api_key=self._get_api_key(), base_url=self._get_base_url(), ) def _is_of_series(self, model: str, series: str) -> bool: if model.startswith(series) and len(model) > 1: # avoid false positive: check if the next character is a number (ex: o3 model) try: int(model[len(series)]) return True except ValueError: return False return False def _is_minimal_params_model(self, model: str) -> bool: for minimal_params_series in MINIMAL_PARAMS_SERIES_MODELS: if self._is_of_series(model, minimal_params_series): return True for minimal_params_model in MINIMAL_PARAMS_MODELS: if model.startswith(minimal_params_model): return True return False async def _get_signal_from_gpt( self, messages, model=None, max_tokens=3000, n=1, stop=None, temperature=0.5 ): self._ensure_rate_limit() try: model = model or self.model supports_params = not self._is_minimal_params_model(model) if not supports_params: self.logger.info( f"The {model} model does not support every required parameter, results might not be as accurate " f"as with other models." ) completions = await self._get_client().chat.completions.create( model=model, max_completion_tokens=max_tokens, n=n, stop=stop, temperature=temperature if supports_params else openai.NOT_GIVEN, messages=messages ) self._update_token_usage(completions.usage.total_tokens) return completions.choices[0].message.content except ( openai.BadRequestError, openai.UnprocessableEntityError # error in request )as err: if "does not support 'system' with this model" in str(err): desc = err.message err_message = ( f"The \"{model}\" model can't be used with {SYSTEM} prompts. " f"It should be added to NO_SYSTEM_PROMPT_MODELS: {desc}" ) else: err_message = f"Error when running request with model {model} (invalid request): {err}" raise errors.InvalidRequestError(err_message) from err except openai.NotFoundError as err: self.logger.error(f"Model {model} not found: {err}. Available models: {', '.join(self.models)}") self.creation_error_message = str(err) except openai.AuthenticationError as err: self.logger.error(f"Invalid OpenAI api key: {err}") self.creation_error_message = str(err) except Exception as err: raise errors.InvalidRequestError( f"Unexpected error when running request with model {model}: {err}" ) from err def _get_signal_from_stored_signals( self, exchange: str, symbol: str, time_frame: str, version: str, candle_open_time: float, ) -> str: try: return self.stored_signals.get_node([exchange, symbol, time_frame, version, candle_open_time]).node_value except tree.NodeExistsError: return "" async def _fetch_signal_from_stored_signals( self, exchange: str, symbol: str, time_frame: str, version: str, candle_open_time: float, ) -> typing.Optional[str]: authenticator = authentication.Authenticator.instance() try: return await authenticator.get_gpt_signal( exchange, symbol, commons_enums.TimeFrames(time_frame), candle_open_time, version ) except Exception as err: self.logger.exception(err, True, f"Error when fetching gpt signal: {err}") def store_signal_history( self, exchange: str, symbol: str, time_frame: commons_enums.TimeFrames, version: str, signals_by_candle_open_time, ): tf = time_frame.value for candle_open_time, signal in signals_by_candle_open_time.items(): self.stored_signals.set_node_at_path( signal, str, [exchange, symbol, tf, version, candle_open_time] ) def has_signal_history( self, exchange: str, symbol: str, time_frame: commons_enums.TimeFrames, min_timestamp: float, max_timestamp: float, version: str ): for ts in (min_timestamp, max_timestamp): if self._get_signal_from_stored_signals( exchange, symbol, time_frame.value, version, time_frame_manager.get_last_timeframe_time(time_frame, ts) ) == "": return False return True async def _fetch_and_store_history( self, authenticator, exchange_name, symbol, time_frame, version, min_timestamp: float, max_timestamp: float ): # no need to fetch a particular exchange signals_by_candle_open_time = await authenticator.get_gpt_signals_history( None, symbol, time_frame, time_frame_manager.get_last_timeframe_time(time_frame, min_timestamp), time_frame_manager.get_last_timeframe_time(time_frame, max_timestamp), version ) if signals_by_candle_open_time: self.logger.info( f"Fetched {len(signals_by_candle_open_time)} ChatGPT signals " f"history for {symbol} {time_frame} on any exchange." ) else: self.logger.error( f"No ChatGPT signal history for {symbol} on {time_frame.value} for any exchange with {version}. " f"Please check {self._supported_history_url()} to get the list of supported signals history." ) self.store_signal_history( exchange_name, symbol, time_frame, version, signals_by_candle_open_time ) @staticmethod def is_setup_correctly(config): return True async def fetch_gpt_history( self, exchange_name: str, symbols: list, time_frames: list, version: str, start_timestamp: float, end_timestamp: float ): authenticator = authentication.Authenticator.instance() coros = [ self._fetch_and_store_history( authenticator, exchange_name, symbol, time_frame, version, start_timestamp, end_timestamp ) for symbol in symbols for time_frame in time_frames if not self.has_signal_history(exchange_name, symbol, time_frame, start_timestamp, end_timestamp, version) ] if coros: await asyncio.gather(*coros) def clear_signal_history(self): self.stored_signals.clear() def allow_token_limit_update(self): return self._env_daily_token_limit == self.NO_TOKEN_LIMIT_VALUE def apply_daily_token_limit_if_possible(self, updated_limit: int): # do not allow updating daily_tokens_limit when set from environment variables if self.allow_token_limit_update(): self._daily_tokens_limit = updated_limit def _supported_history_url(self): return f"{community.IdentifiersProvider.COMMUNITY_URL}/features/chatgpt-trading" def _ensure_rate_limit(self): if self.last_consumed_token_date != datetime.date.today(): self.consumed_daily_tokens = 0 self.last_consumed_token_date = datetime.date.today() if self._daily_tokens_limit == self.NO_TOKEN_LIMIT_VALUE: return if self.consumed_daily_tokens >= self._daily_tokens_limit: raise errors.RateLimitError( f"Daily rate limit reached (used {self.consumed_daily_tokens} out of {self._daily_tokens_limit})" ) def _update_token_usage(self, consumed_tokens): self.consumed_daily_tokens += consumed_tokens self.logger.debug(f"Consumed {consumed_tokens} tokens. {self.consumed_daily_tokens} consumed tokens today.") def check_required_config(self, config): if self._env_secret_key is not None or self.use_stored_signals_only() or self._get_base_url(): return True try: config_key = config[services_constants.CONIG_OPENAI_SECRET_KEY] return bool(config_key) and config_key not in commons_constants.DEFAULT_CONFIG_VALUES except KeyError: return False def has_required_configuration(self): try: if self.use_stored_signals_only(): return True return self.check_required_config( self.config[services_constants.CONFIG_CATEGORY_SERVICES].get(services_constants.CONFIG_GPT, {}) ) except KeyError: return False def get_required_config(self): return [] if self._env_secret_key else [services_constants.CONIG_OPENAI_SECRET_KEY] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/chatgpt" def get_type(self) -> str: return services_constants.CONFIG_GPT def get_website_url(self): return "https://platform.openai.com/overview" def get_logo(self): return "https://upload.wikimedia.org/wikipedia/commons/0/04/ChatGPT_logo.svg" def _get_api_key(self): key = ( self._env_secret_key or self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_GPT].get( services_constants.CONIG_OPENAI_SECRET_KEY, None ) ) if key and not fields_utils.has_invalid_default_config_value(key): return key if self._get_base_url(): # no key and custom base url: use random key return uuid.uuid4().hex return key def _get_base_url(self): value = self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_GPT].get( services_constants.CONIG_LLM_CUSTOM_BASE_URL ) if fields_utils.has_invalid_default_config_value(value): return None return value or None async def prepare(self) -> None: try: if self.use_stored_signals_only(): self.logger.info(f"Skipping GPT - OpenAI models fetch as self.use_stored_signals_only() is True") return if self._get_base_url(): self.logger.info(f"Using custom LLM url: {self._get_base_url()}") fetched_models = await self._get_client().models.list() if fetched_models.data: self.logger.info(f"Fetched {len(fetched_models.data)} models") self.models = [d.id for d in fetched_models.data] else: self.logger.info("No fetched models") self.models = [] if self.model not in self.models: if self._get_base_url(): self.logger.info( f"Custom LLM available models are: {self.models}. " f"Please select one of those in your evaluator configuration." ) else: self.logger.warning( f"Warning: the default '{self.model}' model is not in available LLM models from the " f"selected LLM provider. " f"Available models are: {self.models}. Please select an available model when configuring your " f"evaluators." ) except openai.AuthenticationError as err: self.logger.error(f"Invalid OpenAI api key: {err}") self.creation_error_message = str(err) except Exception as err: self.logger.exception(err, True, f"Unexpected error when initializing GPT service: {err}") def _is_healthy(self): return self.use_stored_signals_only() or (self._get_api_key() and self.models) def get_successful_startup_message(self): return f"GPT configured and ready. {len(self.models)} AI models are available. " \ f"Using {'stored signals' if self.use_stored_signals_only() else self.models}.", \ self._is_healthy() def use_stored_signals_only(self): return not self.config async def stop(self): pass ================================================ FILE: Services/Services_bases/gpt_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GPTService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/reddit_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .reddit import RedditService ================================================ FILE: Services/Services_bases/reddit_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["RedditService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/reddit_service/reddit.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncpraw import octobot_services.constants as services_constants import octobot_services.services as services import octobot.constants as constants class RedditService(services.AbstractService): CLIENT_ID = "client-id" CLIENT_SECRET = "client-secret" def __init__(self): super().__init__() self.reddit_api = None def get_fields_description(self): return { self.CLIENT_ID: "Your client ID.", self.CLIENT_SECRET: "Your client ID secret.", } def get_default_value(self): return { self.CLIENT_ID: "", self.CLIENT_SECRET: "" } def get_required_config(self): return [self.CLIENT_ID, self.CLIENT_SECRET] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/reddit" @staticmethod def is_setup_correctly(config): return services_constants.CONFIG_REDDIT in config[services_constants.CONFIG_CATEGORY_SERVICES] \ and services_constants.CONFIG_SERVICE_INSTANCE in config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_REDDIT] def create_reddit_api(self): self.reddit_api = \ asyncpraw.Reddit(client_id= self.config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_REDDIT][ self.CLIENT_ID], client_secret= self.config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_REDDIT][ self.CLIENT_SECRET], user_agent='bot', **self.mocked_asyncpraw_ini() ) async def prepare(self): if not self.reddit_api: try: self.create_reddit_api() except KeyError: asyncpraw.createIni() def get_type(self): return services_constants.CONFIG_REDDIT def get_website_url(self): return "https://www.reddit.com" def get_endpoint(self): return self.reddit_api def has_required_configuration(self): return services_constants.CONFIG_CATEGORY_SERVICES in self.config \ and services_constants.CONFIG_REDDIT in self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ and self.check_required_config( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_REDDIT]) def get_successful_startup_message(self): return f"Successfully initialized.", True def mocked_asyncpraw_ini(self): # asyncpraw praw.ini file is sometimes not found in binary env, mock its values. # mock values from https://github.com/praw-dev/praw/blob/master/praw/praw.ini using [DEFAULT] # warning, on updating the asycpraw lib, make sure this file did not change # last update: 24 aug 2022 with asyncpraw==7.5.0 # file: # [DEFAULT] # # A boolean to indicate whether or not to check for package updates. # check_for_updates = True # # # Object to kind mappings # comment_kind = t1 # message_kind = t4 # redditor_kind = t2 # submission_kind = t3 # subreddit_kind = t5 # trophy_kind = t6 # # # The URL prefix for OAuth-related requests. # oauth_url = https: // oauth.reddit.com # # # The amount of seconds of ratelimit to sleep for upon encountering a specific type of 429 error. # ratelimit_seconds = 5 # # # The URL prefix for regular requests. # reddit_url = https: // www.reddit.com # # # The URL prefix for short URLs. # short_url = https: // redd.it # # # The timeout for requests to Reddit in number of seconds # timeout = 16 return { "check_for_updates": "False", # local overwrite to avoid update check at startup "comment_kind": "t1", "message_kind": "t4", "redditor_kind": "t2", "submission_kind": "t3", "subreddit_kind": "t5", "trophy_kind": "t6", "oauth_url": "https://oauth.reddit.com", "ratelimit_seconds": "5", "reddit_url": "https://www.reddit.com", "short_url": "https://redd.it", "timeout": "16", } ================================================ FILE: Services/Services_bases/telegram_api_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .telegram_api import TelegramApiService ================================================ FILE: Services/Services_bases/telegram_api_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramApiService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/telegram_api_service/telegram_api.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import logging import os import octobot_commons.constants as common_constants import octobot_commons.logging as bot_logging import telegram import telethon import octobot.constants as constants import octobot_services.constants as services_constants import octobot_services.enums as services_enums import octobot_services.services as services import octobot_tentacles_manager.api as tentacles_manager_api class TelegramApiService(services.AbstractService): LOGGERS = ["TelegramApiService.client.updates", "TelegramApiService.extensions.messagepacker", "TelegramApiService.network.mtprotosender", "TelegramApiService.client.downloads", "telethon.crypto.aes", "telethon.crypto.aesctr"] DOWNLOADS_FOLDER = "Downloads" def __init__(self): super().__init__() self.telegram_client: telethon.TelegramClient = None self.user_account = None self.connected = False self.tentacle_resources_path = tentacles_manager_api.get_tentacle_resources_path(self.__class__) bot_logging.set_logging_level(self.LOGGERS, logging.WARNING) def get_fields_description(self): return { services_constants.CONFIG_API: "App api key.", services_constants.CONFIG_API_HASH: "App api hash.", services_constants.CONFIG_TELEGRAM_PHONE: "Your telegram phone number (beginning with '+' country code).", } def get_default_value(self): return { services_constants.CONFIG_API: "", services_constants.CONFIG_API_HASH: "", services_constants.CONFIG_TELEGRAM_PHONE: "" } def add_event_handler(self, callback, event): if self.telegram_client: self.telegram_client.add_event_handler(callback, event) def get_required_config(self): return [services_constants.CONFIG_API, services_constants.CONFIG_API_HASH] def get_read_only_info(self) -> list[services.ReadOnlyInfo]: return [ services.ReadOnlyInfo( f"Connected as {self.user_account.username}", f"https://telegram.me/{self.user_account.username}", services_enums.ReadOnlyInfoType.COPYABLE ) ] if self.connected and self.user_account else [] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/telegram/telegram-api" @staticmethod def is_setup_correctly(config): return services_constants.CONFIG_TELEGRAM_API in config[services_constants.CONFIG_CATEGORY_SERVICES] \ and services_constants.CONFIG_SERVICE_INSTANCE in config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_TELEGRAM_API] async def prepare(self): if not self.telegram_client: try: self.telegram_client = telethon.TelegramClient(f"{common_constants.USER_FOLDER}/telegram-api", self.config[services_constants.CONFIG_CATEGORY_SERVICES] [services_constants.CONFIG_TELEGRAM_API] [services_constants.CONFIG_API], self.config[services_constants.CONFIG_CATEGORY_SERVICES] [services_constants.CONFIG_TELEGRAM_API] [services_constants.CONFIG_API_HASH], base_logger=self.get_name()) await self.telegram_client.start( phone= self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TELEGRAM_API] [services_constants.CONFIG_TELEGRAM_PHONE] ) self.user_account = await self.telegram_client.get_me() self.connected = True except Exception as e: self.logger.error(f"Failed to connect to Telegram Api : {e}") def is_running(self): return self.telegram_client.is_connected() def get_type(self): return services_constants.CONFIG_TELEGRAM_API def get_website_url(self): return "https://telegram.org/" def get_endpoint(self): return self.telegram_client def get_brand_name(self): return "telegram" async def stop(self): if self.connected: self.telegram_client.disconnect() self.connected = False @staticmethod def get_is_enabled(config): return services_constants.CONFIG_CATEGORY_SERVICES in config \ and services_constants.CONFIG_TELEGRAM_API in config[services_constants.CONFIG_CATEGORY_SERVICES] def has_required_configuration(self): return services_constants.CONFIG_CATEGORY_SERVICES in self.config \ and services_constants.CONFIG_TELEGRAM_API in self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ and self.check_required_config( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TELEGRAM_API]) \ and self.get_is_enabled(self.config) async def send_message_as_user(self, content, markdown=False, reply_to_message_id=None) -> telegram.Message: kwargs = {} if markdown: kwargs[services_constants.MESSAGE_PARSE_MODE] = telegram.parsemode.ParseMode.MARKDOWN try: if content: return await self.telegram_client.send_message(entity=self.user_account.username, message=content, reply_to=reply_to_message_id, **kwargs) except Exception as e: self.logger.error(f"Failed to send message : {e}") return None async def download_media_from_message(self, message, source=""): downloads_folder = os.path.join(self.tentacle_resources_path, self.DOWNLOADS_FOLDER, source) if not os.path.exists(downloads_folder): os.makedirs(downloads_folder) await self.telegram_client.download_media(message=message, file=downloads_folder) return downloads_folder def get_successful_startup_message(self): try: return f"Successfully connected to {self.user_account.username} account.", True except Exception as e: self.logger.error(f"Error when connecting to Telegram API ({e}): invalid telegram configuration.") return "", False ================================================ FILE: Services/Services_bases/telegram_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .telegram import TelegramService ================================================ FILE: Services/Services_bases/telegram_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/telegram_service/telegram.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import logging import typing import telegram import telegram.ext import telegram.request import telegram.error import octobot_commons.logging as bot_logging import octobot_services.constants as services_constants import octobot_services.enums as services_enums import octobot_services.services as services import octobot.constants as constants class TelegramService(services.AbstractService): CONNECT_TIMEOUT = 7 # default is 5, use 7 to take slow connections into account CHAT_ID = "chat-id" LOGGERS = ["telegram._bot", "telegram.ext.Updater", "telegram.ext.ExtBot", "hpack.hpack", "hpack.table"] def __init__(self): super().__init__() self.telegram_app: telegram.ext.Application = None self._has_bot = False self.chat_id = None self.users = [] self.text_chat_dispatcher = {} self._bot_url = None self.connected = False def get_fields_description(self): return { self.CHAT_ID: "ID of your chat.", services_constants.CONFIG_TOKEN: "Token given by 'botfather'.", services_constants.CONFIG_USERNAMES_WHITELIST: "List of telegram usernames (user's @ identifier without " "@) allowed to talk to your OctoBot. This allows you to " "limit your OctoBot's telegram interactions to specific " "users only. No access restriction if left empty." } def get_default_value(self): return { self.CHAT_ID: "", services_constants.CONFIG_TOKEN: "", services_constants.CONFIG_USERNAMES_WHITELIST: [], } def get_required_config(self): return [self.CHAT_ID, services_constants.CONFIG_TOKEN] def get_read_only_info(self) -> list[services.ReadOnlyInfo]: return [ services.ReadOnlyInfo( 'Connected to:', self._bot_url, services_enums.ReadOnlyInfoType.CLICKABLE ) ] if self._bot_url else [] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/telegram" @staticmethod def is_setup_correctly(config): return services_constants.CONFIG_TELEGRAM in config[services_constants.CONFIG_CATEGORY_SERVICES] \ and services_constants.CONFIG_SERVICE_INSTANCE in config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_TELEGRAM] async def prepare(self): if not self.telegram_app: bot_logging.set_logging_level(self.LOGGERS, logging.WARNING) self.chat_id = self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TELEGRAM][ self.CHAT_ID] # force http 1.1 requests to avoid the following issue: # Invalid input ConnectionInputs.RECV_WINDOW_UPDATE in state ConnectionState.CLOSED # from https://github.com/python-telegram-bot/python-telegram-bot/issues/3556 self.telegram_app = telegram.ext.ApplicationBuilder()\ .token( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TELEGRAM][ services_constants.CONFIG_TOKEN] )\ .request(telegram.request.HTTPXRequest( connect_timeout=self.CONNECT_TIMEOUT ))\ .get_updates_request(telegram.request.HTTPXRequest( connect_timeout=self.CONNECT_TIMEOUT ))\ .build() try: await self._start_app() except telegram.error.InvalidToken as e: self.logger.error(f"Telegram configuration error: {e} Your Telegram token is invalid.") except telegram.error.NetworkError as e: self.log_connection_error_message(e) async def _start_app(self): self.logger.debug("Initializing telegram connection") self.connected = True await self.telegram_app.initialize() if self.telegram_app.post_init: await self.telegram_app.post_init(self.telegram_app) async def _start_bot(self, polling_error_callback): self._has_bot = True await self.telegram_app.updater.start_polling(error_callback=polling_error_callback) await self.telegram_app.start() async def _stop_app(self): await self.telegram_app.shutdown() if self.telegram_app.post_shutdown: await self.telegram_app.post_shutdown(self.telegram_app) self.connected = False async def _stop_bot(self): if self.telegram_app.updater.running: # await self.telegram_app.updater.shutdown() try: await self.telegram_app.updater.stop() except telegram.error.TimedOut as err: # can happen, ignore error self.logger.debug(f"Ignored {err} when stopping telegram bot") if self.telegram_app.running: await self.telegram_app.stop() if self.telegram_app.post_stop: await self.telegram_app.post_stop(self.telegram_app) self._has_bot = False def register_text_polling_handler(self, chat_types: telegram.constants.ChatType, handler): for chat_type in chat_types: self.text_chat_dispatcher[chat_type] = handler async def text_handler(self, update: telegram.Update, context: telegram.ext.ContextTypes.DEFAULT_TYPE): chat_type = update.effective_chat.type if chat_type in self.text_chat_dispatcher: await self.text_chat_dispatcher[chat_type](update, context) else: self.logger.info(f"No handler for telegram update of type {chat_type}, update: {update}") def add_text_handler(self): self.telegram_app.add_handler( telegram.ext.MessageHandler(telegram.ext.filters.TEXT, self.text_handler) ) def add_handlers(self, handlers): self.telegram_app.add_handlers(handlers) def add_error_handler(self, handler): self.telegram_app.add_error_handler(handler) def is_registered(self, user_key): return user_key in self.users def register_user(self, user_key): self.users.append(user_key) async def start_bot(self, polling_error_callback): try: if not self._has_bot and self.users: await self._start_bot(polling_error_callback) self.logger.debug("Started telegram bot") self.add_text_handler() except Exception as e: raise e def is_running(self): return self.telegram_app and self.telegram_app.running def get_type(self): return services_constants.CONFIG_TELEGRAM def get_website_url(self): return "https://telegram.org/" def get_endpoint(self): return self.telegram_app async def stop(self): if self.connected: if self._has_bot: await self._stop_bot() await self._stop_app() @staticmethod def get_is_enabled(config): return services_constants.CONFIG_CATEGORY_SERVICES in config \ and services_constants.CONFIG_TELEGRAM in config[services_constants.CONFIG_CATEGORY_SERVICES] def has_required_configuration(self): return services_constants.CONFIG_CATEGORY_SERVICES in self.config \ and services_constants.CONFIG_TELEGRAM in self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ and self.check_required_config( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TELEGRAM]) \ and self.get_is_enabled(self.config) async def send_message(self, content, markdown=False, reply_to_message_id=None) -> typing.Optional[telegram.Message]: if not self.chat_id: self.logger.warning( "Impossible to send telegram message: please provide a chat id in telegram configuration." ) return None kwargs = {} if markdown: kwargs[services_constants.MESSAGE_PARSE_MODE] = telegram.constants.ParseMode.MARKDOWN try: if content: return await self.telegram_app.bot.send_message( chat_id=self.chat_id, text=content, reply_to_message_id=reply_to_message_id, **kwargs ) except telegram.error.TimedOut: # retry on failing try: return await self.telegram_app.bot.send_message( chat_id=self.chat_id, text=content, reply_to_message_id=reply_to_message_id, **kwargs ) except telegram.error.TimedOut as e: self.logger.error(f"Failed to send message : {e}") except telegram.error.InvalidToken as e: self.logger.error(f"Failed to send message ({e}): invalid telegram configuration.") return None def _fetch_bot_url(self): self._bot_url = f"https://web.telegram.org/#/im?p={self.telegram_app.bot.name}" return self._bot_url def get_successful_startup_message(self): try: self.telegram_app.bot.name except RuntimeError: # raised by telegram_app.bot.name property when not properly initialized (invalid token, etc) # error has already been logged in prepare() return "", False return f"Successfully initialized and accessible at: {self._fetch_bot_url()}.", True ================================================ FILE: Services/Services_bases/trading_view_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .trading_view import TradingViewService ================================================ FILE: Services/Services_bases/trading_view_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TradingViewService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/trading_view_service/trading_view.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import hashlib import uuid import octobot_commons.authentication as authentication import octobot_services.constants as services_constants import octobot_services.enums as services_enums import octobot_services.services as services import octobot.constants as constants class TradingViewService(services.AbstractService): def __init__(self): super().__init__() self.requires_token = None self.token = None self.use_email_alert = None self._webhook_url = None @staticmethod def is_setup_correctly(config): return True @staticmethod def get_is_enabled(config): return True def has_required_configuration(self): return True def get_required_config(self): return [ services_constants.CONFIG_REQUIRE_TRADING_VIEW_TOKEN, services_constants.CONFIG_TRADING_VIEW_TOKEN, # disabled until TradingView email alerts are restored # services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS ] def get_fields_description(self): return { services_constants.CONFIG_REQUIRE_TRADING_VIEW_TOKEN: "When enabled the TradingView webhook will require your " "tradingview.com token to process any signal.", services_constants.CONFIG_TRADING_VIEW_TOKEN: "Your personal unique tradingview.com token. Can be used to ensure only your " "TradingView signals are triggering your OctoBot in case someone else get " "your webhook link. You can change it at any moment but remember to change it " "on your tradingview.com signal account as well.", services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS: ( f"When enabled, your OctoBot will trade using the free TradingView email alerts. When disabled, " f"a webhook configuration is required to trade using TradingView alerts. Requires the " f"{constants.OCTOBOT_EXTENSION_PACKAGE_1_NAME}." ), } def get_default_value(self): return { services_constants.CONFIG_REQUIRE_TRADING_VIEW_TOKEN: False, services_constants.CONFIG_TRADING_VIEW_TOKEN: self.get_security_token(uuid.uuid4().hex), # disabled until TradingView email alerts are restored # services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS: False, } def is_improved_by_extensions(self) -> bool: return True def get_read_only_info(self) -> list[services.ReadOnlyInfo]: read_only_info = [] auth = authentication.Authenticator.instance() if auth.is_tradingview_email_confirmed() and (email_address := auth.get_saved_tradingview_email()): read_only_info.append(services.ReadOnlyInfo( 'Email address:', email_address, services_enums.ReadOnlyInfoType.COPYABLE, configuration_title="Configure on TradingView", configuration_path="tradingview_email_config" )) else: pass # disabled until TradingView email alerts are restored # read_only_info.append(services.ReadOnlyInfo( # 'Email address:', "Generate email", services_enums.ReadOnlyInfoType.CTA, # path="tradingview_email_config" # )) if self._webhook_url: read_only_info.append(services.ReadOnlyInfo( 'Webhook url:', self._webhook_url, services_enums.ReadOnlyInfoType.READONLY if self._webhook_url == services_constants.TRADING_VIEW_USING_EMAIL_INSTEAD_OF_WEBHOOK else services_enums.ReadOnlyInfoType.COPYABLE, )) return read_only_info @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/tradingview" def get_endpoint(self) -> None: return None def get_type(self) -> None: return services_constants.CONFIG_TRADING_VIEW def get_website_url(self): return "https://www.tradingview.com/?aff_id=27595" def get_logo(self): return "https://in.tradingview.com/static/images/favicon.ico" async def prepare(self) -> None: try: self.requires_token = \ self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TRADING_VIEW][ services_constants.CONFIG_REQUIRE_TRADING_VIEW_TOKEN] self.token = \ self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TRADING_VIEW][ services_constants.CONFIG_TRADING_VIEW_TOKEN] self.use_email_alert = \ self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TRADING_VIEW].get( services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS, False ) except KeyError: if self.requires_token is None: self.requires_token = self.get_default_value()[services_constants.CONFIG_REQUIRE_TRADING_VIEW_TOKEN] if self.token is None: self.token = self.get_default_value()[services_constants.CONFIG_TRADING_VIEW_TOKEN] if self.use_email_alert is None: self.use_email_alert = self.get_default_value().get(services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS, False) # save new values into config file updated_config = { services_constants.CONFIG_REQUIRE_TRADING_VIEW_TOKEN: self.requires_token, services_constants.CONFIG_TRADING_VIEW_TOKEN: self.token, } if self.use_email_alert: # only save CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS if use_email_alert is True # (to keep the option of users still using it) updated_config[services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS] = self.use_email_alert self.save_service_config(services_constants.CONFIG_TRADING_VIEW, updated_config) @staticmethod def get_security_token(pin_code): """ Generate unique token from pin. This adds a marginal amount of security. :param pin_code: the pin code to use :return: the generated token """ token = hashlib.sha224(pin_code.encode('utf-8')) return token.hexdigest() def register_webhook_url(self, webhook_url): self._webhook_url = webhook_url def get_successful_startup_message(self): return "", True ================================================ FILE: Services/Services_bases/twitter_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .twitter import TwitterService ================================================ FILE: Services/Services_bases/twitter_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TwitterService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/twitter_service/twitter.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import requests # comment imports to remove twitter from dependencies when tentacle is disabled # import twitter # import twitter.api # import twitter.twitter_utils import octobot_services.constants as services_constants import octobot_services.enums as services_enums import octobot_services.services as services import octobot.constants as constants # disable inheritance to disable tentacle visibility. Disabled as starting from feb 9 2023, API is now paid only # class TwitterService(services.AbstractService): class TwitterService: API_KEY = "api-key" API_SECRET = "api-secret" ACCESS_TOKEN = "access-token" ACCESS_TOKEN_SECRET = "access-token-secret" def __init__(self): super().__init__() self.twitter_api = None self._account_url = None def get_fields_description(self): return { self.API_KEY: "Your Twitter API key.", self.API_SECRET: "Your Twitter API-secret key.", self.ACCESS_TOKEN: "Your Twitter access token key.", self.ACCESS_TOKEN_SECRET: "Your Twitter access token secret key." } def get_default_value(self): return { self.API_KEY: "", self.API_SECRET: "", self.ACCESS_TOKEN: "", self.ACCESS_TOKEN_SECRET: "" } def get_required_config(self): return [self.API_KEY, self.API_SECRET, self.ACCESS_TOKEN, self.ACCESS_TOKEN_SECRET] def get_read_only_info(self) -> list[services.ReadOnlyInfo]: return [ services.ReadOnlyInfo( 'Connected to:', self._account_url, services_enums.ReadOnlyInfoType.CLICKABLE ) ] if self._account_url else [] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/twitter" @staticmethod def is_setup_correctly(config): return services_constants.CONFIG_TWITTER in config[services_constants.CONFIG_CATEGORY_SERVICES] \ and services_constants.CONFIG_SERVICE_INSTANCE in config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_TWITTER] def get_user_id(self, user_account): user = self.twitter_api.GetUser(screen_name=user_account) return user.id def get_history(self, user_id): return self.twitter_api.GetUserTimeline(user_id=user_id) async def prepare(self): if not self.twitter_api: self.twitter_api = twitter.Api( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TWITTER][ self.API_KEY], self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TWITTER][ self.API_SECRET], self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TWITTER][ self.ACCESS_TOKEN], self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TWITTER][ self.ACCESS_TOKEN_SECRET], sleep_on_rate_limit=True ) def get_type(self): return services_constants.CONFIG_TWITTER def get_website_url(self): return "https://twitter.com/" def get_endpoint(self): return self.twitter_api def has_required_configuration(self): return services_constants.CONFIG_CATEGORY_SERVICES in self.config \ and services_constants.CONFIG_TWITTER in self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ and self.check_required_config( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_TWITTER]) @staticmethod def decode_tweet(tweet): if "extended_tweet" in tweet and "full_text" in tweet: return tweet["extended_tweet"]["full_text"] elif "text" in tweet: return tweet["text"] return "" async def post(self, content, error_on_failure=True): try: return self.split_if_necessary_and_send_tweet(content=content, tweet_id=None) except Exception as e: error = f"Failed to send tweet : {e} tweet:{content}" if error_on_failure: self.logger.error(error) else: self.logger.info(error) return None async def respond(self, tweet_id, content, error_on_failure=True): try: return self.split_if_necessary_and_send_tweet(content=content, tweet_id=tweet_id) except Exception as e: error = f"Failed to send response tweet : {e} tweet:{content}" if error_on_failure: self.logger.error(error) else: self.logger.info(error) return None def split_if_necessary_and_send_tweet(self, content, counter=None, counter_max=None, tweet_id=None): # add twitter counter at the beginning if counter is not None and counter_max is not None: content = f"{counter}/{counter_max} {content}" counter += 1 # get the current content size post_size = twitter.twitter_utils.calc_expected_status_length(content) # check if the current content size can be posted if post_size > twitter.api.CHARACTER_LIMIT: # calculate the number of post required for the whole content if not counter_max: counter_max = post_size // twitter.api.CHARACTER_LIMIT counter = 1 # post the current tweet # no async call possible yet post = self.twitter_api.PostUpdate(status=content[:twitter.api.CHARACTER_LIMIT], in_reply_to_status_id=tweet_id) # recursive call for all post while content > twitter.api.CHARACTER_LIMIT self.split_if_necessary_and_send_tweet(content[twitter.api.CHARACTER_LIMIT:], counter=counter, counter_max=counter_max, tweet_id=tweet_id) return post else: return self.twitter_api.PostUpdate(status=content[:twitter.api.CHARACTER_LIMIT], in_reply_to_status_id=tweet_id) def get_tweet_text(self, tweet): try: return TwitterService.decode_tweet(tweet) except Exception as e2: self.logger.error(e2) return "" @staticmethod def get_twitter_id_from_url(url): return str(url).split("/")[-1] def get_tweet(self, tweet_id): return self.twitter_api.GetStatus(tweet_id) def _fetch_twitter_url(self): self._account_url = f"https://twitter.com/{self.twitter_api.VerifyCredentials().screen_name}" return self._account_url def get_successful_startup_message(self): try: return f"Successfully initialized and accessible at: {self._fetch_twitter_url()}.", True except requests.exceptions.ConnectionError as e: self.log_connection_error_message(e) return "", False ================================================ FILE: Services/Services_bases/web_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .web import WebService ================================================ FILE: Services/Services_bases/web_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["WebService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/web_service/web.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import socket import octobot_commons.constants as commons_constants import octobot_services.constants as services_constants import octobot_services.services as services import octobot.constants as constants LOCAL_HOST_IP = "127.0.0.1" class WebService(services.AbstractService): BACKTESTING_ENABLED = True def __init__(self): super().__init__() self.web_app = None self.requires_password = None self.password_hash = None def get_fields_description(self): return { services_constants.CONFIG_WEB_PORT: "Port to access your OctoBot web interface from.", services_constants.CONFIG_AUTO_OPEN_IN_WEB_BROWSER: "When enabled, OctoBot will open the web interface on your web " "browser upon startup.", services_constants.CONFIG_WEB_REQUIRES_PASSWORD: "When enabled, OctoBot web interface will be protected by a password. " "Failing 10 times to enter this password will block the user and require " "OctoBot to restart before being able to retry to authenticate.", services_constants.CONFIG_WEB_PASSWORD: "Password to enter to access this OctoBot when password protection is enabled. " "Only a hash of this password will be stored." } def get_default_value(self): return { services_constants.CONFIG_WEB_PORT: services_constants.DEFAULT_SERVER_PORT, services_constants.CONFIG_AUTO_OPEN_IN_WEB_BROWSER: True, services_constants.CONFIG_WEB_REQUIRES_PASSWORD: False, services_constants.CONFIG_WEB_PASSWORD: "" } def get_required_config(self): return [services_constants.CONFIG_WEB_PORT] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/web" @staticmethod def is_setup_correctly(config): return services_constants.CONFIG_WEB in config[services_constants.CONFIG_CATEGORY_SERVICES] \ and services_constants.CONFIG_SERVICE_INSTANCE in config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_WEB] @staticmethod def get_is_enabled(config): # allow to disable web interface from config, enabled by default otherwise try: return config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEB][ commons_constants.CONFIG_ENABLED_OPTION] except KeyError: return True def has_required_configuration(self): return self.get_is_enabled(self.config) def get_endpoint(self) -> None: return self.web_app def get_type(self) -> None: return services_constants.CONFIG_WEB def get_website_url(self): return "/home" def get_logo(self): return "static/img/svg/octobot.svg" async def prepare(self) -> None: try: self.requires_password = \ self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEB][ services_constants.CONFIG_WEB_REQUIRES_PASSWORD] self.password_hash = \ self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEB][ services_constants.CONFIG_WEB_PASSWORD] except KeyError: if self.requires_password is None: self.requires_password = self.get_default_value()[services_constants.CONFIG_WEB_REQUIRES_PASSWORD] if self.password_hash is None: self.password_hash = self.get_default_value()[services_constants.CONFIG_WEB_PASSWORD] # save new values into config file updated_config = { services_constants.CONFIG_WEB_REQUIRES_PASSWORD: self.requires_password, services_constants.CONFIG_WEB_PASSWORD: self.password_hash } self.save_service_config(services_constants.CONFIG_WEB, updated_config, update=True) @staticmethod def get_should_warn(): return False async def stop(self): if self.web_app: self.web_app.stop() def _get_web_server_port(self): try: return os.getenv( services_constants.ENV_WEB_PORT, self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEB][ services_constants.CONFIG_WEB_PORT] ) except KeyError: return os.getenv(services_constants.ENV_WEB_PORT, services_constants.DEFAULT_SERVER_PORT) def _get_web_server_url(self): port = self._get_web_server_port() try: return f"{os.getenv(services_constants.ENV_WEB_ADDRESS, socket.gethostbyname(socket.gethostname()))}:{port}" except OSError as err: self.logger.warning( f"Impossible to find local web interface url, using default instead: {err} ({err.__class__.__name__})" ) # use localhost by default return f"{LOCAL_HOST_IP}:{port}" def get_successful_startup_message(self): return f"Interface successfully initialized and accessible at: http://{self._get_web_server_url()}.", True ================================================ FILE: Services/Services_bases/webhook_service/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .webhook import WebHookService ================================================ FILE: Services/Services_bases/webhook_service/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["WebHookService"], "tentacles-requirements": [] } ================================================ FILE: Services/Services_bases/webhook_service/webhook.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import logging import os import time import flask import threading import gevent.pywsgi import pyngrok.ngrok as ngrok import pyngrok.exception import octobot_commons.logging as bot_logging import octobot_commons.configuration as configuration import octobot_commons.authentication as authentication import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_services.constants as services_constants import octobot_services.services as services import octobot.constants as constants import octobot.community.errors as community_errors class WebHookService(services.AbstractService): CONNECTION_TIMEOUT = 8 # can take up to 5s on slow setups LOGGERS = ["pyngrok.ngrok", "werkzeug"] def get_fields_description(self): if self.use_web_interface_for_webhook: return {} return { services_constants.CONFIG_ENABLE_OCTOBOT_WEBHOOK: f"Use OctoBot cloud webhook. Requires the {constants.OCTOBOT_EXTENSION_PACKAGE_1_NAME}.", services_constants.CONFIG_ENABLE_NGROK: "Use Ngrok", services_constants.CONFIG_NGROK_TOKEN: "The ngrok token used to expose the webhook to the internet.", services_constants.CONFIG_NGROK_DOMAIN: "[Optional] The ngrok subdomain.", services_constants.CONFIG_WEBHOOK_SERVER_IP: "WebHook bind IP: used for webhook when ngrok is not enabled.", services_constants.CONFIG_WEBHOOK_SERVER_PORT: "WebHook port: used for webhook when ngrok is not enabled." } def get_default_value(self): if self.use_web_interface_for_webhook: return {} return { services_constants.CONFIG_ENABLE_OCTOBOT_WEBHOOK: False, services_constants.CONFIG_ENABLE_NGROK: True, services_constants.CONFIG_NGROK_TOKEN: "", services_constants.CONFIG_NGROK_DOMAIN: "", services_constants.CONFIG_WEBHOOK_SERVER_IP: services_constants.DEFAULT_WEBHOOK_SERVER_IP, services_constants.CONFIG_WEBHOOK_SERVER_PORT: services_constants.DEFAULT_WEBHOOK_SERVER_PORT } def is_improved_by_extensions(self) -> bool: return True def __init__(self): super().__init__() self.use_web_interface_for_webhook = constants.IS_CLOUD_ENV self.use_octobot_cloud_webhook = False self.use_octobot_cloud_email_webhook = False self.ngrok_tunnel = None self.webhook_public_url = "" self.ngrok_enabled = True self.ngrok_domain = None self.service_feed_webhooks = {} self.service_feed_auth_callbacks = {} self.webhook_app = None self.webhook_host = None self.webhook_port = None self.webhook_server = None self.webhook_server_context = None self.webhook_server_thread = None self.connected = None @staticmethod def is_setup_correctly(config): return services_constants.CONFIG_WEBHOOK in config[services_constants.CONFIG_CATEGORY_SERVICES] \ and services_constants.CONFIG_SERVICE_INSTANCE in config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_WEBHOOK] @staticmethod def get_is_enabled(config): return True def check_required_config(self, config): if self.use_web_interface_for_webhook: return True if self.is_using_octobot_cloud_webhook() or self.is_using_octobot_cloud_email_webhook(): return True try: token = config.get(services_constants.CONFIG_NGROK_TOKEN) enabled_ngrok = config.get(services_constants.CONFIG_ENABLE_NGROK, True) if enabled_ngrok: return token and not configuration.has_invalid_default_config_value(token) return not ( configuration.has_invalid_default_config_value( config.get(services_constants.CONFIG_WEBHOOK_SERVER_PORT) ) or configuration.has_invalid_default_config_value( config.get(services_constants.CONFIG_WEBHOOK_SERVER_IP) ) ) except KeyError: return False def has_required_configuration(self): try: return self.check_required_config(self.get_webhook_config()) except KeyError: return False def is_using_octobot_cloud_webhook(self): return self.get_webhook_config().get(services_constants.CONFIG_ENABLE_OCTOBOT_WEBHOOK) def is_using_octobot_cloud_email_webhook(self): return self.config[services_constants.CONFIG_CATEGORY_SERVICES].get( services_constants.CONFIG_TRADING_VIEW, {} ).get( services_constants.CONFIG_TRADING_VIEW_USE_EMAIL_ALERTS, False ) def get_webhook_config(self): return self.config[services_constants.CONFIG_CATEGORY_SERVICES].get(services_constants.CONFIG_WEBHOOK, {}) def get_required_config(self): return [] if self.use_web_interface_for_webhook else \ [services_constants.CONFIG_ENABLE_NGROK, services_constants.CONFIG_NGROK_TOKEN] @classmethod def get_help_page(cls) -> str: return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/tradingview/using-a-webhook" def get_type(self) -> None: return services_constants.CONFIG_WEBHOOK def get_logo(self): return None def is_subscribed(self, feed_name): return feed_name in self.service_feed_webhooks @staticmethod def connect(port, protocol="http", domain=None) -> ngrok.NgrokTunnel: """ Create a new ngrok tunnel :param port: the tunnel local port :param protocol: the protocol to use :return: the ngrok url """ return ngrok.connect(port, protocol, domain=domain) def subscribe_feed(self, service_feed_name, service_feed_callback, auth_callback) -> None: """ Subscribe a service feed to the webhook :param service_feed_name: the service feed name :param service_feed_callback: the service feed callback reference :return: the service feed webhook url """ if service_feed_name not in self.service_feed_webhooks: self.service_feed_webhooks[service_feed_name] = service_feed_callback self.service_feed_auth_callbacks[service_feed_name] = auth_callback return raise KeyError(f"Service feed has already subscribed to a webhook : {service_feed_name}") def get_subscribe_url(self, service_feed_name): if self.use_octobot_cloud_email_webhook: return services_constants.TRADING_VIEW_USING_EMAIL_INSTEAD_OF_WEBHOOK if self.use_octobot_cloud_webhook: return self._get_community_feed_webhook_url() return f"{self.webhook_public_url}/{service_feed_name}" def _prepare_webhook_server(self): try: self.logger.debug(f"Starting local webhook server at {self.webhook_host}:{self.webhook_port}") self.webhook_server = gevent.pywsgi.WSGIServer( (self.webhook_host, self.webhook_port), self.webhook_app, log=None ) self.webhook_server_context = self.webhook_app.app_context() self.webhook_server_context.push() except OSError as e: self.webhook_server = None self.logger.exception(e, False, f"Fail to start webhook : {e}") def _register_webhook_routes(self, blueprint) -> None: @blueprint.route('/') def index(): """ Route to check if webhook server is online """ return '' @blueprint.route('/webhook/', methods=['POST']) def webhook(webhook_name): return self._flask_webhook_call(webhook_name) def _flask_webhook_call(self, webhook_name): if flask.request.method == 'POST': data = flask.request.get_data(as_text=True) if self._default_webhook_call(webhook_name, data): return '', 200 return 'invalid or missing input parameters', 400 flask.abort(405) def _community_webhook_call_factory(self, service_name: str): async def _community_webhook_callback(data: dict) -> bool: return await self._async_default_webhook_call( service_name, data[commons_enums.CommunityFeedAttrs.VALUE.value] ) return _community_webhook_callback def _default_webhook_call(self, webhook_name: str, data: str) -> bool: if self.is_valid_webhook_call(webhook_name, data): self.service_feed_webhooks[webhook_name](data) return True return False async def _async_default_webhook_call(self, webhook_name: str, data: str) -> bool: if self.is_valid_webhook_call(webhook_name, data): await self.service_feed_webhooks[webhook_name](data) return True return False def is_valid_webhook_call(self, webhook_name:str , data: str): if webhook_name in self.service_feed_webhooks: if self.service_feed_auth_callbacks[webhook_name](data): return True else: self.logger.warning(f"Ignored message (wrong token): {data}") return False self.logger.warning(f"Received unknown request from {webhook_name}") return False def is_using_cloud_webhooks(self): return self.use_octobot_cloud_webhook or self.use_octobot_cloud_email_webhook async def prepare(self) -> None: if self.use_web_interface_for_webhook: return if self.is_using_octobot_cloud_email_webhook(): self.use_octobot_cloud_email_webhook = True return if self.is_using_octobot_cloud_webhook(): self.use_octobot_cloud_webhook = True return bot_logging.set_logging_level(self.LOGGERS, logging.WARNING) self.ngrok_enabled = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ services_constants.CONFIG_WEBHOOK].get(services_constants.CONFIG_ENABLE_NGROK, True) if self.ngrok_enabled: ngrok.set_auth_token( self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEBHOOK][ services_constants.CONFIG_NGROK_TOKEN]) self.ngrok_domain = self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_WEBHOOK]\ .get(services_constants.CONFIG_NGROK_DOMAIN, None) if self.ngrok_domain in commons_constants.DEFAULT_CONFIG_VALUES: # ignore default values self.ngrok_domain = None try: self.webhook_host = os.getenv(services_constants.ENV_WEBHOOK_ADDRESS, self.config[services_constants.CONFIG_CATEGORY_SERVICES] [services_constants.CONFIG_WEBHOOK][services_constants.CONFIG_WEBHOOK_SERVER_IP]) except KeyError: self.webhook_host = os.getenv(services_constants.ENV_WEBHOOK_ADDRESS, services_constants.DEFAULT_WEBHOOK_SERVER_IP) try: self.webhook_port = int( os.getenv(services_constants.ENV_WEBHOOK_PORT, self.config[services_constants.CONFIG_CATEGORY_SERVICES] [services_constants.CONFIG_WEBHOOK][services_constants.CONFIG_WEBHOOK_SERVER_PORT])) except KeyError: self.webhook_port = int( os.getenv(services_constants.ENV_WEBHOOK_PORT, services_constants.DEFAULT_WEBHOOK_SERVER_PORT)) def _start_server(self): try: self._prepare_webhook_server() self._register_webhook_routes(self.webhook_app) self.webhook_public_url = f"http://{self.webhook_host}:{self.webhook_port}/webhook" if self.ngrok_enabled: self.ngrok_tunnel = self.connect(self.webhook_port, protocol="http", domain=self.ngrok_domain) self.webhook_public_url = f"{self.ngrok_tunnel.public_url}/webhook" if self.webhook_server: self.connected = True self.webhook_server.serve_forever() except pyngrok.exception.PyngrokNgrokError as e: self.logger.error(f"Error when starting webhook service: Your ngrok.com token might be invalid. ({e})") except Exception as e: self.logger.exception(e, True, f"Error when running webhook service: ({e})") self.connected = False async def _start_isolated_server(self): if self.webhook_app is None: self.webhook_app = flask.Flask(__name__) # gevent WSGI server has to be created in the thread it is started: create everything in this thread self.webhook_server_thread = threading.Thread(target=self._start_server, name=self.get_name()) self.webhook_server_thread.start() start_time = time.time() timeout = False while self.connected is None and not timeout: time.sleep(0.1) timeout = time.time() - start_time > self.CONNECTION_TIMEOUT if timeout: self.logger.error("Webhook took too long to start, now stopping it.") await self.stop() self.connected = False return self.connected is True return True async def _register_on_web_interface(self): import tentacles.Services.Interfaces.web_interface.api as api if not api.has_webhook(self._flask_webhook_call): api.register_webhook(self._flask_webhook_call) authenticator = authentication.Authenticator.instance() if not authenticator.initialized_event.is_set(): await asyncio.wait_for(authenticator.initialized_event.wait(), authenticator.LOGIN_TIMEOUT) try: # deployed bot url self.webhook_public_url = f"{await authenticator.get_deployment_url()}/api/webhook" self.connected = True return True except community_errors.BotError as err: self.logger.exception(err, True, f"Impossible to start web interface based webhook {err}") return False def _get_community_feed_webhook_url(self) -> str: try: authenticator = authentication.Authenticator.instance() bot_identifier = authenticator.get_saved_mqtt_device_uuid() return f"{constants.COMMUNITY_TRADINGVIEW_WEBHOOK_BASE_URL}/{bot_identifier}" except community_errors.NoBotDeviceError: return "" async def _register_on_community_feed(self): authenticator = authentication.Authenticator.instance() bot_identifier = authenticator.get_saved_mqtt_device_uuid() if not authenticator.initialized_event.is_set(): await asyncio.wait_for(authenticator.initialized_event.wait(), authenticator.LOGIN_TIMEOUT) try: for feed_name, channel_type in [ (services_constants.TRADINGVIEW_WEBHOOK_SERVICE_NAME, commons_enums.CommunityChannelTypes.TRADINGVIEW) ]: await authenticator.register_feed_callback( channel_type, self._community_webhook_call_factory(feed_name), identifier=bot_identifier ) self.webhook_public_url = self._get_community_feed_webhook_url() self.connected = True return True except community_errors.BotError as err: self.logger.exception(err, True, f"Impossible to start OctoBot cloud based webhook {err}") return False async def start_webhooks(self) -> bool: if self.use_web_interface_for_webhook: return await self._register_on_web_interface() if self.is_using_cloud_webhooks(): try: return await self._register_on_community_feed() except community_errors.NoBotDeviceError: raise community_errors.ExtensionRequiredError( f"A connected OctoBot account using the {constants.OCTOBOT_EXTENSION_PACKAGE_1_NAME} " f"is required to use OctoBot {'email' if self.use_octobot_cloud_email_webhook else 'webhook' } " f"alerts for TradingView." ) return await self._start_isolated_server() def _is_healthy(self): return ( self.use_web_interface_for_webhook or self.is_using_octobot_cloud_webhook() or self.is_using_octobot_cloud_email_webhook() or (self.webhook_host is not None and self.webhook_port is not None) ) def get_successful_startup_message(self): webhook_endpoint = f"ngrok address" if self.use_web_interface_for_webhook: webhook_endpoint = "web interface webhook api" if self.is_using_octobot_cloud_webhook() or self.is_using_octobot_cloud_email_webhook(): webhook_endpoint = "OctoBot cloud network" return f"Webhook configured on {webhook_endpoint}", self._is_healthy() async def stop(self): if not self.use_web_interface_for_webhook and self.connected: ngrok.kill() if self.webhook_server: try: self.webhook_server.stop() except Exception as err: self.logger.warning(f"Error when stopping webhook server: {err}") ================================================ FILE: Services/Services_feeds/google_service_feed/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .google_feed import GoogleServiceFeed from .google_feed import TrendTopic ================================================ FILE: Services/Services_feeds/google_service_feed/google_feed.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import time import aiohttp import simplifiedpytrends.exceptions import simplifiedpytrends.request import octobot_services.channel as services_channel import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import tentacles.Services.Services_bases as Services_bases class GoogleServiceFeedChannel(services_channel.AbstractServiceFeedChannel): pass class TrendTopic: def __init__(self, refresh_time, keywords, category=0, time_frame="today 5-y", geo="", grop=""): self.keywords = keywords self.sanitized_keywords = [ keyword.replace(" ", "+") for keyword in keywords ] self.category = category self.time_frame = time_frame self.geo = geo self.grop = grop self.refresh_time = refresh_time self.next_refresh = time.time() def __str__(self): return f"{self.keywords} {self.time_frame}" class GoogleServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = GoogleServiceFeedChannel REQUIRED_SERVICES = [Services_bases.GoogleService] def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) self.trends_req_builder = None self.trends_topics = [] def _initialize(self): # if the url changes (google sometimes changes it), use the following line: # trends_req.GENERAL_URL = "https://trends.google.com/trends/explore" self.trends_req_builder = simplifiedpytrends.request.TrendReq(hl='en-US', tz=0) # merge new config into existing config def update_feed_config(self, config): self.trends_topics.extend(topic for topic in config[services_constants.CONFIG_TREND_TOPICS] if topic not in self.trends_topics) def _something_to_watch(self): return bool(self.trends_topics) def _get_sleep_time_before_next_wakeup(self): closest_wakeup = min(topic.next_refresh for topic in self.trends_topics) return max(0, closest_wakeup - time.time()) async def _get_topic_trend(self, topic): self.logger.debug(f"Fetching trend on {topic.keywords} over {topic.time_frame}") await self.trends_req_builder.async_build_payload(kw_list=topic.sanitized_keywords, cat=topic.category, timeframe=topic.time_frame, geo=topic.geo, gprop=topic.grop) topic.next_refresh = time.time() + topic.refresh_time return await self.trends_req_builder.async_interest_over_time() async def _push_update_and_wait(self): for topic in self.trends_topics: if time.time() >= topic.next_refresh: interest_over_time = await self._get_topic_trend(topic) if interest_over_time: await self._async_notify_consumers( { services_constants.FEED_METADATA: f"{topic};{interest_over_time}", services_constants.CONFIG_TREND: interest_over_time, services_constants.CONFIG_TREND_DESCRIPTION: topic } ) await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) async def _update_loop(self): async with aiohttp.ClientSession() as session: self.trends_req_builder.aiohttp_session = session while not self.should_stop: try: await self._push_update_and_wait() except simplifiedpytrends.exceptions.ResponseError as e: self.logger.exception(e, True, f"Error when fetching Google trends feed: {e} " f"(response text: {await e.response.text()})") self.should_stop = True except Exception as e: self.logger.exception(e, True, f"Error when receiving Google feed: ({e})") self.should_stop = True return False async def _start_service_feed(self): try: asyncio.create_task(self._update_loop()) except Exception as e: self.logger.exception(e, True, f"Error when initializing Google trends feed: {e}") return False return True ================================================ FILE: Services/Services_feeds/google_service_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GoogleServiceFeed"], "tentacles-requirements": ["google_service"] } ================================================ FILE: Services/Services_feeds/reddit_service_feed/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .reddit_feed import RedditServiceFeed ================================================ FILE: Services/Services_feeds/reddit_service_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["RedditServiceFeed"], "tentacles-requirements": ["reddit_service"] } ================================================ FILE: Services/Services_feeds/reddit_service_feed/reddit_feed.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import time import asyncprawcore.exceptions import logging import octobot_commons.constants as commons_constants import octobot_services.channel as services_channel import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import tentacles.Services.Services_bases as Services_bases class RedditServiceFeedChannel(services_channel.AbstractServiceFeedChannel): pass class RedditServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = RedditServiceFeedChannel REQUIRED_SERVICES = [Services_bases.RedditService] MAX_CONNECTION_ATTEMPTS = 10 def __init__(self, config, main_async_loop, bot_id): service_feeds.AbstractServiceFeed.__init__(self, config, main_async_loop, bot_id) self.subreddits = None self.counter = 0 self.connect_attempts = 0 self.credentials_ok = False self.listener_task = None # merge new config into existing config def update_feed_config(self, config): if services_constants.CONFIG_REDDIT_SUBREDDITS in self.feed_config: self.feed_config[services_constants.CONFIG_REDDIT_SUBREDDITS] = { **self.feed_config[services_constants.CONFIG_REDDIT_SUBREDDITS], **config[services_constants.CONFIG_REDDIT_SUBREDDITS]} else: self.feed_config[services_constants.CONFIG_REDDIT_SUBREDDITS] = config[ services_constants.CONFIG_REDDIT_SUBREDDITS] def _init_subreddits(self): self.subreddits = "" for symbol in self.feed_config[services_constants.CONFIG_REDDIT_SUBREDDITS]: for subreddit in self.feed_config[services_constants.CONFIG_REDDIT_SUBREDDITS][symbol]: if subreddit not in self.subreddits: if self.subreddits: self.subreddits = self.subreddits + "+" + subreddit else: self.subreddits = self.subreddits + subreddit def _initialize(self): if not self.subreddits: self._init_subreddits() def _something_to_watch(self): return services_constants.CONFIG_REDDIT_SUBREDDITS in self.feed_config and self.feed_config[ services_constants.CONFIG_REDDIT_SUBREDDITS] @staticmethod def _get_entry_weight(entry_age): if entry_age > 0: # entry in history => weight proportional to entry's age # last 12 hours: weight = 4 # last 2 days: weight = 3 # last 7 days: weight = 2 # older: weight = 1 if entry_age / commons_constants.HOURS_TO_SECONDS <= 12: return 4 elif entry_age / commons_constants.DAYS_TO_SECONDS <= 2: return 3 elif entry_age / commons_constants.DAYS_TO_SECONDS <= 7: return 2 else: return 1 # new entry => max weight return 5 async def _start_listener(self): # avoid debug log at each asyncprawcore fetch logging.getLogger("asyncprawcore").setLevel(logging.WARNING) subreddit = await self.services[0].get_endpoint().subreddit(self.subreddits) start_time = time.time() async for entry in subreddit.stream.submissions(): self.credentials_ok = True self.connect_attempts = 0 self.counter += 1 # check if we are in the 100 history or if it's a new entry (new posts are more valuables) # the older the entry is, the les weight it gets entry_age_when_feed_started_in_sec = start_time - entry.created_utc entry_weight = self._get_entry_weight(entry_age_when_feed_started_in_sec) await self._async_notify_consumers( { services_constants.FEED_METADATA: entry.subreddit.display_name.lower(), services_constants.CONFIG_REDDIT_ENTRY: entry, services_constants.CONFIG_REDDIT_ENTRY_WEIGHT: entry_weight } ) async def _start_listener_task(self): while not self.should_stop and self.connect_attempts < self.MAX_CONNECTION_ATTEMPTS: try: await self._start_listener() except asyncprawcore.exceptions.RequestException: # probably a connexion loss, try again time.sleep(self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC) except asyncprawcore.exceptions.InvalidToken as e: # expired, try again self.logger.exception(e, True, f"Error when receiving Reddit feed: '{e}'") self.logger.info(f"Try to continue after {self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC} seconds.") time.sleep(self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC) except asyncprawcore.exceptions.ServerError as e: # server error, try again self.logger.exception(e, True, "Error when receiving Reddit feed: '{e}'") self.logger.info(f"Try to continue after {self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC} seconds.") time.sleep(self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC) except asyncprawcore.exceptions.OAuthException as e: self.logger.exception(e, True, f"Error when receiving Reddit feed: '{e}' this may mean that reddit " f"login info in config.json are wrong") self.keep_running = False self.should_stop = True except asyncprawcore.exceptions.ResponseException as e: message_complement = "this may mean that reddit login info in config.json are invalid." \ if not self.credentials_ok else \ f"Try to continue after {self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC} seconds." self.logger.exception(e, True, f"Error when receiving Reddit feed: '{e}' this may mean {message_complement}") if not self.credentials_ok: self.connect_attempts += 1 else: self.connect_attempts += 0.1 time.sleep(self._SLEEPING_TIME_BEFORE_RECONNECT_ATTEMPT_SEC) except Exception as e: self.logger.exception(e, True, f"Error when receiving Reddit feed: '{e}'") self.keep_running = False self.should_stop = True return False async def _start_service_feed(self): self.listener_task = asyncio.create_task(self._start_listener_task()) return True async def stop(self): await super().stop() if self.listener_task is not None: self.listener_task.cancel() self.listener_task = None ================================================ FILE: Services/Services_feeds/telegram_api_service_feed/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .telegram_api_feed import TelegramApiServiceFeed ================================================ FILE: Services/Services_feeds/telegram_api_service_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramApiServiceFeed"], "tentacles-requirements": ["telegram_api_service"] } ================================================ FILE: Services/Services_feeds/telegram_api_service_feed/telegram_api_feed.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import telethon import octobot_services.channel as services_channel import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import tentacles.Services.Services_bases as Services_bases class TelegramApiServiceFeedChannel(services_channel.AbstractServiceFeedChannel): pass class TelegramApiServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = TelegramApiServiceFeedChannel REQUIRED_SERVICES = [Services_bases.TelegramApiService] def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) self.feed_config = { services_constants.CONFIG_TELEGRAM_ALL_CHANNEL: True, } def update_feed_config(self, config): pass def _add_event_handler(self): self.services[0].add_event_handler(self.message_handler, telethon.events.NewMessage) async def message_handler(self, event): try: display_name = self.get_display_name(await event.get_sender()) if self.feed_config[services_constants.CONFIG_TELEGRAM_ALL_CHANNEL]: media_output_path = None if event.message.media is not None: media_output_path = await self.services[0].download_media_from_message(message=event.message, source=display_name) await self.feed_send_coroutine( { services_constants.CONFIG_MESSAGE_SENDER: display_name, services_constants.CONFIG_MESSAGE_CONTENT: event.text, services_constants.CONFIG_IS_GROUP_MESSAGE: event.is_group, services_constants.CONFIG_IS_CHANNEL_MESSAGE: event.is_channel, services_constants.CONFIG_IS_PRIVATE_MESSAGE: event.is_private, services_constants.CONFIG_MEDIA_PATH: media_output_path, } ) else: self.logger.debug(f"Ignored message from {display_name}: not in followed telegram users " f"(message: {event.text})") except Exception as e: self.logger.error(f"Fail to parse incoming message : {e}") def get_display_name(self, entity): if isinstance(entity, telethon.types.User): if entity.last_name and entity.first_name: return f"{entity.first_name} {entity.last_name}" elif entity.first_name: return entity.first_name elif entity.last_name: return entity.last_name else: return "" elif isinstance(entity, (telethon.types.Chat, telethon.types.ChatForbidden, telethon.types.Channel)): return entity.title return "" def _something_to_watch(self): return self.feed_config[services_constants.CONFIG_TELEGRAM_ALL_CHANNEL] @staticmethod def _get_service_layer_service_feed(): return Services_bases.TelegramApiService def _initialize(self): self._add_event_handler() async def _start_service_feed(self): return True ================================================ FILE: Services/Services_feeds/telegram_service_feed/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .telegram_feed import TelegramServiceFeed ================================================ FILE: Services/Services_feeds/telegram_service_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TelegramServiceFeed"], "tentacles-requirements": ["telegram_service"] } ================================================ FILE: Services/Services_feeds/telegram_service_feed/telegram_feed.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import telegram import telegram.ext import octobot_services.channel as services_channel import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import tentacles.Services.Services_bases as Services_bases class TelegramServiceFeedChannel(services_channel.AbstractServiceFeedChannel): pass class TelegramServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = TelegramServiceFeedChannel REQUIRED_SERVICES = [Services_bases.TelegramService] HANDLED_CHATS = [telegram.constants.ChatType.GROUP, telegram.constants.ChatType.CHANNEL] def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) self.feed_config = { services_constants.CONFIG_TELEGRAM_ALL_CHANNEL: False, services_constants.CONFIG_TELEGRAM_CHANNEL: [] } # configure the whitelist of Telegram groups/channels to listen to # merge new config into existing config def update_feed_config(self, config): self.feed_config[services_constants.CONFIG_TELEGRAM_CHANNEL].extend( channel for channel in config[services_constants.CONFIG_TELEGRAM_CHANNEL] if channel not in self.feed_config[services_constants.CONFIG_TELEGRAM_CHANNEL] ) # if True, disable channel whitelist and listen to every group/channel it is invited to def set_listen_to_all_groups_and_channels(self, activate=True): self.feed_config[services_constants.CONFIG_TELEGRAM_ALL_CHANNEL] = activate def _register_to_service(self): if not self.services[0].is_registered(self.get_name()): self.services[0].register_user(self.get_name()) self.services[0].register_text_polling_handler(self.HANDLED_CHATS, self._feed_callback) async def _feed_callback(self, update: telegram.Update, _: telegram.ext.ContextTypes.DEFAULT_TYPE): message = update.effective_message.text chat = update.effective_chat.title if ( self.feed_config[services_constants.CONFIG_TELEGRAM_ALL_CHANNEL] or chat in self.feed_config[services_constants.CONFIG_TELEGRAM_CHANNEL] ): message_desc = str(update) await self._async_notify_consumers( { services_constants.FEED_METADATA: message_desc, services_constants.CONFIG_GROUP_MESSAGE: update, services_constants.CONFIG_GROUP_MESSAGE_DESCRIPTION: message.lower() } ) else: self.logger.debug(f"Ignored message from {chat} chat: not in followed telegram chats (message: {message})") def _something_to_watch(self): return self.feed_config[services_constants.CONFIG_TELEGRAM_ALL_CHANNEL] or self.feed_config[ services_constants.CONFIG_TELEGRAM_CHANNEL] @staticmethod def _get_service_layer_service_feed(): return Services_bases.TelegramService def _initialize(self): self._register_to_service() async def _start_service_feed(self): return True ================================================ FILE: Services/Services_feeds/trading_view_service_feed/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .trading_view_feed import TradingViewServiceFeed ================================================ FILE: Services/Services_feeds/trading_view_service_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TradingViewServiceFeed"], "tentacles-requirements": ["trading_view_service"] } ================================================ FILE: Services/Services_feeds/trading_view_service_feed/trading_view_feed.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_services.channel as services_channel import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import octobot_commons.authentication as authentication import tentacles.Services.Services_bases as Services_bases class TradingViewServiceFeedChannel(services_channel.AbstractServiceFeedChannel): pass class TradingViewServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = TradingViewServiceFeedChannel REQUIRED_SERVICES = [Services_bases.WebHookService, Services_bases.TradingViewService] def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) self.webhook_service_name = services_constants.TRADINGVIEW_WEBHOOK_SERVICE_NAME self.webhook_service_url = "" def _something_to_watch(self): return bool(self.channel.consumers) def ensure_callback_auth(self, data) -> bool: if self.services[1].requires_token: split_result = data.split("TOKEN=") if len(split_result) > 1: token = split_result[1].strip().split("\n")[0] return self.services[1].token == token return False # no token expected return True def webhook_callback(self, data): self.logger.info(f"Received : {data}") self._notify_consumers( { services_constants.FEED_METADATA: data, } ) async def async_webhook_callback(self, data): self.logger.info(f"Received : {data}") await self._async_notify_consumers( { services_constants.FEED_METADATA: data, } ) def _register_to_service(self): service = self.services[0] if not service.is_subscribed(self.webhook_service_name): callback = self.async_webhook_callback if service.is_using_cloud_webhooks() else self.webhook_callback service.subscribe_feed( self.webhook_service_name, callback, self.ensure_callback_auth ) def _initialize(self): self._register_to_service() async def _start_service_feed(self): success = await self.services[0].start_webhooks() self.webhook_service_url = self.services[0].get_subscribe_url(self.webhook_service_name) if success: self.services[1].register_webhook_url(self.webhook_service_url) address_details = ( f"email address is: {authentication.Authenticator.instance().get_saved_tradingview_email()}" if self.services[0].use_octobot_cloud_email_webhook else f"webhook url is: {self.webhook_service_url}" ) self.logger.info(f"Your OctoBot's TradingView {address_details} " f"the pin code for this alert is: {self.services[1].token}") return success ================================================ FILE: Services/Services_feeds/twitter_service_feed/__init__.py ================================================ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: from .twitter_feed import TwitterServiceFeed ================================================ FILE: Services/Services_feeds/twitter_service_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TwitterServiceFeed"], "tentacles-requirements": ["twitter_service"] } ================================================ FILE: Services/Services_feeds/twitter_service_feed/twitter_feed.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import threading # comment imports to remove twitter from dependencies when tentacle is disabled # import twitter import octobot_services.channel as services_channel import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import tentacles.Services.Services_bases as Services_bases # disable inheritance to disable tentacle visibility. Disabled as starting from feb 9 2023, API is now paid only # class TwitterServiceFeedChannel(services_channel.AbstractServiceFeedChannel): class TwitterServiceFeedChannel: pass # disable inheritance to disable tentacle visibility. Disabled as starting from feb 9 2023, API is now paid only # class TwitterServiceFeed(service_feeds.AbstractServiceFeed, threading.Thread): class TwitterServiceFeed: FEED_CHANNEL = TwitterServiceFeedChannel REQUIRED_SERVICES = [Services_bases.TwitterService] def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) threading.Thread.__init__(self, name=self.get_name()) self.user_ids = [] self.hashtags = [] self.counter = 0 async def _inner_start(self) -> bool: threading.Thread.start(self) return True # merge new config into existing config def update_feed_config(self, config): if services_constants.CONFIG_TWITTERS_ACCOUNTS in self.feed_config: self.feed_config[services_constants.CONFIG_TWITTERS_ACCOUNTS] = { **self.feed_config[services_constants.CONFIG_TWITTERS_ACCOUNTS], **config[services_constants.CONFIG_TWITTERS_ACCOUNTS]} else: self.feed_config[services_constants.CONFIG_TWITTERS_ACCOUNTS] = config[ services_constants.CONFIG_TWITTERS_ACCOUNTS] if services_constants.CONFIG_TWITTERS_HASHTAGS in self.feed_config: self.feed_config[services_constants.CONFIG_TWITTERS_HASHTAGS] = { **self.feed_config[services_constants.CONFIG_TWITTERS_HASHTAGS], **config[services_constants.CONFIG_TWITTERS_HASHTAGS]} else: self.feed_config[services_constants.CONFIG_TWITTERS_HASHTAGS] = config[ services_constants.CONFIG_TWITTERS_HASHTAGS] def _init_users_accounts(self): tempo_added_accounts = [] for symbol in self.feed_config[services_constants.CONFIG_TWITTERS_ACCOUNTS]: for account in self.feed_config[services_constants.CONFIG_TWITTERS_ACCOUNTS][symbol]: if account not in tempo_added_accounts: tempo_added_accounts.append(account) try: self.user_ids.append(str(self.services[0].get_user_id(account))) except twitter.TwitterError as e: self.logger.error(account + " : " + str(e)) def _init_hashtags(self): for symbol in self.feed_config[services_constants.CONFIG_TWITTERS_HASHTAGS]: for hashtag in self.feed_config[services_constants.CONFIG_TWITTERS_HASHTAGS][symbol]: if hashtag not in self.hashtags: self.hashtags.append(hashtag) def _initialize(self): if not self.user_ids: self._init_users_accounts() if not self.hashtags: self._init_hashtags() def _something_to_watch(self): return (services_constants.CONFIG_TWITTERS_HASHTAGS in self.feed_config and self.feed_config[ services_constants.CONFIG_TWITTERS_HASHTAGS]) \ or (services_constants.CONFIG_TWITTERS_ACCOUNTS in self.feed_config and self.feed_config[ services_constants.CONFIG_TWITTERS_ACCOUNTS]) async def _start_listener(self): for tweet in self.services[0].get_endpoint().GetStreamFilter(follow=self.user_ids, track=self.hashtags, stall_warnings=True): self.counter += 1 string_tweet = self.services[0].get_tweet_text(tweet) if string_tweet: tweet_desc = str(tweet).lower() self._notify_consumers( { services_constants.FEED_METADATA: tweet_desc, services_constants.CONFIG_TWEET: tweet, services_constants.CONFIG_TWEET_DESCRIPTION: string_tweet.lower() } ) async def _start_service_feed(self): while not self.should_stop: try: await self._start_listener() except twitter.error.TwitterError as e: self.logger.exception(e, True, f"Error when receiving Twitter feed: {e.message} ({e})") self.should_stop = True except Exception as e: self.logger.exception(e, True, f"Error when receiving Twitter feed: ({e})") self.should_stop = True return False ================================================ FILE: Trading/Exchange/ascendex/__init__.py ================================================ # Drakkar-Software OctoBot-Private-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .ascendex_exchange import * ================================================ FILE: Trading/Exchange/ascendex/ascendex_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import decimal import octobot_commons.enums import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges class AscendEx(exchanges.RestExchange): DESCRIPTION = "" # text content of errors due to unhandled IP white list issues EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # ascendex {"code":200001,"message":"You have setup IP allowed list for this key. Your IP address () is not # in the allowed list.","reason":"AUTHENTICATION_FAILED"} ("ip allowed list", "not in the allowed list"), ] BUY_STR = "Buy" SELL_STR = "Sell" SUPPORT_FETCHING_CANCELLED_ORDERS = False FIX_MARKET_STATUS = True ACCOUNTS = { trading_enums.AccountTypes.CASH: 'cash', trading_enums.AccountTypes.MARGIN: 'margin', trading_enums.AccountTypes.FUTURE: 'futures', # currently in beta } @classmethod def get_name(cls): return 'ascendex' def get_adapter_class(self): return AscendexCCXTAdapter async def switch_to_account(self, account_type): # TODO pass def parse_account(self, account): return trading_enums.AccountTypes[account.lower()] async def get_my_recent_trades(self, symbol=None, since=None, limit=None, **kwargs): # On AscendEx, account recent trades is available under fetch_closed_orders return await super().get_closed_orders(symbol=symbol, since=since, limit=limit, **kwargs) async def get_symbol_prices(self, symbol: str, time_frame: octobot_commons.enums.TimeFrames, limit: int = None, **kwargs: dict) -> typing.Optional[list]: if limit is None: # force default limit on AscendEx since it's not used by default in fetch_ohlcv options = self.connector.client.safe_value(self.connector.client.options, 'fetchOHLCV', {}) limit = self.connector.client.safe_integer(options, 'limit', 500) return await super().get_symbol_prices(symbol, time_frame, limit, **kwargs) class AscendexCCXTAdapter(exchanges.CCXTAdapter): def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Exchange/ascendex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["AscendEx"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/ascendex_websocket_feed/__init__.py ================================================ from .ascendex_websocket import AscendexCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/ascendex_websocket_feed/ascendex_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.ascendex.ascendex_exchange as ascendex_exchange class AscendexCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: Feeds.UNSUPPORTED.value, Feeds.CANDLE: True, } @classmethod def get_name(cls): return ascendex_exchange.AscendEx.get_name() def get_adapter_class(self, adapter_class): return ascendex_exchange.AscendexCCXTAdapter ================================================ FILE: Trading/Exchange/ascendex_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["AscendexCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/ascendex_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/ascendex_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...ascendex_websocket_feed import AscendexCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() async with websocket_test_tools.ws_exchange_manager(config, AscendexCryptofeedWebsocketConnector.get_name()) \ as exchange_manager_instance: await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=AscendexCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value, }, time_before_assert=20 ) ================================================ FILE: Trading/Exchange/binance/__init__.py ================================================ from .binance_exchange import Binance ================================================ FILE: Trading/Exchange/binance/binance_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import typing import enum import ccxt import octobot_commons.constants as commons_constants import octobot_commons.symbols as symbols import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges import octobot_trading.errors as errors import octobot_trading.constants as trading_constants import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums import octobot_trading.util as trading_util import octobot_trading.personal_data as personal_data class BinanceMarkets(enum.Enum): SPOT = "spot" LINEAR = "linear" INVERSE = "inverse" class Binance(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees # should be fetched using recent trades. SUPPORTS_SET_MARGIN_TYPE_ON_OPEN_POSITIONS = False # set False when the exchange refuses to change margin type # when an associated position is open # binance {"code":-4048,"msg":"Margin type cannot be changed if there exists position."} # Set True when the "limit" param when fetching order books is taken into account SUPPORTS_CUSTOM_LIMIT_ORDER_BOOK_FETCH = True # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on futures trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request # not supported or need custom mechanics with batch orders trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } # text content of errors due to orders not found errors EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # Binance ex: DDoSProtection('binance {"code":-2015,"msg":"Invalid API-key, IP, or permissions for action."}') ("key", "permissions for action"), ] # text content of errors due to traded assets for account EXCHANGE_ACCOUNT_TRADED_SYMBOL_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # Binance ex: InvalidOrder binance {"code":-2010,"msg":"This symbol is not permitted for this account."} ("symbol", "not permitted", "for this account"), # ccxt.base.errors.InvalidOrder: binance {"code":-2010,"msg":"Symbol not whitelisted for API key."} ("symbol", "not whitelisted"), ] # text content of errors due to a closed position on the exchange. Relevant for reduce-only orders EXCHANGE_CLOSED_POSITION_ERRORS: typing.List[typing.Iterable[str]] = [ # doesn't seem to happen on binance ] # text content of errors due to an order that would immediately trigger if created. Relevant for stop losses EXCHANGE_ORDER_IMMEDIATELY_TRIGGER_ERRORS: typing.List[typing.Iterable[str]] = [ # binance {"code":-2021,"msg":"Order would immediately trigger."} ("order would immediately trigger", ) ] # text content of errors due to an order that can't be cancelled on exchange (because filled or already cancelled) EXCHANGE_ORDER_UNCANCELLABLE_ERRORS: typing.List[typing.Iterable[str]] = [ ('Unknown order sent', ) ] # set when the exchange can allow users to pay fees in a custom currency (ex: BNB on binance) LOCAL_FEES_CURRENCIES: typing.List[str] = ["BNB"] # Name of the price param to give ccxt to edit a stop loss STOP_LOSS_EDIT_PRICE_PARAM = ccxt_enums.ExchangeOrderCCXTUnifiedParams.STOP_PRICE.value BUY_STR = "BUY" SELL_STR = "SELL" INVERSE_TYPE = "inverse" LINEAR_TYPE = "linear" def __init__( self, config, exchange_manager, exchange_config_by_exchange: typing.Optional[dict[str, dict]], connector_class=None ): self._futures_account_types = self._infer_account_types(exchange_manager) super().__init__(config, exchange_manager, exchange_config_by_exchange, connector_class=connector_class) @classmethod def get_name(cls): return 'binance' def get_adapter_class(self): return BinanceCCXTAdapter @staticmethod def get_default_reference_market(exchange_name: str) -> str: return "USDC" def supports_native_edit_order(self, order_type: trading_enums.TraderOrderType) -> bool: # return False when default edit_order can't be used and order should always be canceled and recreated instead is_stop = order_type in ( trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT ) if self.exchange_manager.is_future: # replace not supported in futures stop orders return not is_stop async def get_account_id(self, **kwargs: dict) -> str: try: with self.connector.error_describer(): if self.exchange_manager.is_future: raw_binance_balance = await self.connector.client.fapiPrivateV3GetBalance() # accountAlias = unique account code # from https://binance-docs.github.io/apidocs/futures/en/#futures-account-balance-v3-user_data return raw_binance_balance[0]["accountAlias"] else: raw_balance = await self.connector.client.fetch_balance() return raw_balance[ccxt_constants.CCXT_INFO]["uid"] except (KeyError, IndexError): # should not happen raise def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: """ from: https://developers.binance.com/docs/derivatives/usds-margined-futures/common-definition#max_num_orders https://developers.binance.com/docs/binance-spot-api-docs/filters#max_num_orders [ {"filterType": "PRICE_FILTER", "maxPrice": "1000000.00000000", "minPrice": "0.01000000", "tickSize": "0.01000000"}, {"filterType": "LOT_SIZE", "maxQty": "9000.00000000", "minQty": "0.00001000", "stepSize": "0.00001000"}, {"filterType": "ICEBERG_PARTS", "limit": "10"}, {"filterType": "MARKET_LOT_SIZE", "maxQty": "115.46151096", "minQty": "0.00000000", "stepSize": "0.00000000"}, {"filterType": "TRAILING_DELTA", "maxTrailingAboveDelta": "2000", "maxTrailingBelowDelta": "2000", "minTrailingAboveDelta": "10", "minTrailingBelowDelta": "10"}, {"askMultiplierDown": "0.2", "askMultiplierUp": "5", "avgPriceMins": "5", "bidMultiplierDown": "0.2", "bidMultiplierUp": "5", "filterType": "PERCENT_PRICE_BY_SIDE"}, {"applyMaxToMarket": False, "applyMinToMarket": True, "avgPriceMins": "5", "filterType": "NOTIONAL", "maxNotional": "9000000.00000000", "minNotional": "5.00000000"}, {"filterType": "MAX_NUM_ORDERS", "maxNumOrders": "200"}, {"filterType": "MAX_NUM_ALGO_ORDERS", "maxNumAlgoOrders": "5"} ] => usually: - SPOT: MAX_NUM_ORDERS 200 MAX_NUM_ALGO_ORDERS 5 - FUTURES: MAX_NUM_ORDERS 200 MAX_NUM_ALGO_ORDERS 10 """ try: market_status = self.get_market_status(symbol, with_fixer=False) filters = market_status[ccxt_constants.CCXT_INFO]["filters"] key = "MAX_NUM_ALGO_ORDERS" if personal_data.is_stop_order(order_type) else "MAX_NUM_ORDERS" value_key = "maxNumAlgoOrders" if personal_data.is_stop_order(order_type) else "maxNumOrders" fallback_value_key = "limit" # sometimes, "limit" is the key for filter_element in filters: if filter_element.get("filterType") == key: key = value_key if value_key in filter_element else fallback_value_key return int(filter_element[key]) raise ValueError(f"{key} not found in filters: {filters}") except Exception as err: default_count = super().get_max_orders_count(symbol, order_type) self.logger.exception( err, True, f"Error when computing max orders count: {err}. Using default value: {default_count}" ) return default_count def uses_demo_trading_instead_of_sandbox(self) -> bool: if self.exchange_manager.is_future: return True return False def _infer_account_types(self, exchange_manager): account_types = [] symbol_counts = trading_util.get_symbol_types_counts(exchange_manager.config, True) # only enable the trading type with the majority of asked symbols # todo remove this and use both types when exchange-side multi portfolio is enabled linear_count = symbol_counts.get(trading_enums.FutureContractType.LINEAR_PERPETUAL.value, 0) inverse_count = symbol_counts.get(trading_enums.FutureContractType.INVERSE_PERPETUAL.value, 0) if linear_count >= inverse_count: account_types.append(self.LINEAR_TYPE) # allows to fetch linear markets if inverse_count: exchange_manager.logger.error( f"For now, due to the inverse and linear portfolio split on Binance Futures, OctoBot only " f"supports either linear or inverse trading at a time. Ignoring {inverse_count} inverse " f"futures trading pair as {linear_count} linear futures trading pairs are enabled." ) else: account_types.append(self.INVERSE_TYPE) # allows to fetch inverse markets if linear_count: exchange_manager.logger.error( f"For now, due to the inverse and linear portfolio split on Binance Futures, OctoBot only " f"supports either linear or inverse trading at a time. Ignoring {linear_count} linear " f"futures trading pair as {inverse_count} inverse futures trading pairs are enabled." ) return account_types @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_additional_connector_config(self): config = { ccxt_constants.CCXT_OPTIONS: { "quoteOrderQty": True, # enable quote conversion for market orders "recvWindow": 60000, # default is 10000, avoid time related issues "fetchPositions": "account", # required to fetch empty positions as well "filterClosed": False, # return empty positions as well } } if self.FETCH_MIN_EXCHANGE_MARKETS: config[ccxt_constants.CCXT_OPTIONS][ccxt_constants.CCXT_FETCH_MARKETS] = ( [ BinanceMarkets.LINEAR.value, BinanceMarkets.INVERSE.value ] if self.exchange_manager.is_future else [BinanceMarkets.SPOT.value] ) return config def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: signature_identifier = "signature=" return bool( ( url and signature_identifier in url # for GET & DELETE requests ) or ( body and signature_identifier in body # for other requests ) ) async def get_balance(self, **kwargs: dict): if self.exchange_manager.is_future: balance = [] for account_type in self._futures_account_types: balance.append(await super().get_balance(**kwargs, subType=account_type)) # todo remove this and use both types when exchange-side multi portfolio is enabled # there will only be 1 balance as both linear and inverse are not supported simultaneously # (only 1 _futures_account_types is allowed for now) return balance[0] return await super().get_balance(**kwargs) def get_order_additional_params(self, order) -> dict: params = {} if self.exchange_manager.is_future: params["reduceOnly"] = order.reduce_only return params def order_request_kwargs_factory( self, exchange_order_id: str, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs ) -> dict: params = kwargs or {} try: if "stop" not in params: order_type = ( order_type or self.exchange_manager.exchange_personal_data.orders_manager.get_order( None, exchange_order_id=exchange_order_id ).order_type ) params["stop"] = ( personal_data.is_stop_order(order_type) or personal_data.is_take_profit_order(order_type) ) except KeyError as err: self.logger.warning( f"Order {exchange_order_id} not found in order manager: considering it a regular (no stop/take profit) order {err}" ) return params def fetch_stop_order_in_different_request(self, symbol: str) -> bool: # Override in tentacles when stop orders need to be fetched in a separate request from CCXT # Binance futures uses the algo orders endpoint for stop orders (but not for inverse orders) return self.exchange_manager.is_future and not symbols.parse_symbol(symbol).is_inverse() async def _create_market_sell_order( self, symbol, quantity, price=None, reduce_only: bool = False, params=None ) -> dict: # force price to None to avoid selling using quote amount (force market sell quantity in base amount) return await super()._create_market_sell_order( symbol, quantity, price=None, reduce_only=reduce_only, params=params ) async def set_symbol_partial_take_profit_stop_loss(self, symbol: str, inverse: bool, tp_sl_mode: trading_enums.TakeProfitStopLossMode): """ take profit / stop loss mode does not exist on binance futures """ async def get_positions(self, symbols=None, **kwargs: dict) -> list: positions = [] if "useV2" not in kwargs: kwargs["useV2"] = True #V2 api is required to fetch empty positions (not retured in V3) if "subType" in kwargs: return _filter_positions(await super().get_positions(symbols=symbols, **kwargs)) for account_type in self._futures_account_types: kwargs["subType"] = account_type positions += await super().get_positions(symbols=symbols, **kwargs) return _filter_positions(positions) async def get_position(self, symbol: str, **kwargs: dict) -> dict: # fetchPosition() supports option markets only # => use get_positions return (await self.get_positions(symbols=[symbol], **kwargs))[0] async def get_symbol_leverage(self, symbol: str, **kwargs: dict): """ :param symbol: the symbol :return: the current symbol leverage multiplier """ # leverage is in position return self.connector.adapter.adapt_leverage(await self.get_position(symbol)) async def get_all_currencies_price_ticker(self, **kwargs: dict) -> typing.Optional[dict[str, dict]]: if "subType" in kwargs or not self.exchange_manager.is_future: return await super().get_all_currencies_price_ticker(**kwargs) # futures with unspecified subType: fetch both linear and inverse tickers linear_tickers = await super().get_all_currencies_price_ticker(subType=self.LINEAR_TYPE, **kwargs) inverse_tickers = await super().get_all_currencies_price_ticker(subType=self.INVERSE_TYPE, **kwargs) return {**linear_tickers, **inverse_tickers} async def set_symbol_margin_type(self, symbol: str, isolated: bool, **kwargs: dict): """ Set the symbol margin type :param symbol: the symbol :param isolated: when False, margin type is cross, else it's isolated :return: the update result """ try: return await super().set_symbol_margin_type(symbol, isolated, **kwargs) except ccxt.ExchangeError as err: raise errors.NotSupported(err) from err class BinanceCCXTAdapter(exchanges.CCXTAdapter): STOP_ORDERS = [ "stop_market", "stop", # futures "stop_loss", "stop_loss_limit" # spot ] TAKE_PROFITS_ORDERS = [ "take_profit_market", "take_profit_limit", # futures "take_profit" # spot ] BINANCE_DEFAULT_FUNDING_TIME = 8 * commons_constants.HOURS_TO_SECONDS def fix_order(self, raw, symbol=None, **kwargs): fixed = super().fix_order(raw, symbol=symbol, **kwargs) self._adapt_order_type(fixed) if fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.STATUS.value, None) == "PENDING_NEW": # PENDING_NEW order are old orders on binance and should be considered as open fixed[ccxt_enums.ExchangeOrderCCXTColumns.STATUS.value] = trading_enums.OrderStatus.OPEN.value return fixed def _adapt_order_type(self, fixed): order_info = fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.INFO.value, {}) info_order_type = (order_info.get("type", {}) or order_info.get("orderType", None) or "").lower() is_stop = info_order_type in self.STOP_ORDERS is_tp = info_order_type in self.TAKE_PROFITS_ORDERS if is_stop or is_tp: if trigger_price := fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.TRIGGER_PRICE.value, None): selling = ( fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.SIDE.value, None) == trading_enums.TradeOrderSide.SELL.value ) updated_type = trading_enums.TradeOrderType.UNKNOWN.value trigger_above = False if is_stop: updated_type = trading_enums.TradeOrderType.STOP_LOSS.value # force price to trigger price fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = trigger_price trigger_above = not selling # sell stop loss triggers when price is lower than target elif is_tp: # updated_type = trading_enums.TradeOrderType.TAKE_PROFIT.value # take profits are not yet handled as such: consider them as limit orders updated_type = trading_enums.TradeOrderType.LIMIT.value # waiting for TP handling if not fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]: fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = trigger_price # waiting for TP handling trigger_above = selling # sell take profit triggers when price is higher than target else: self.logger.error( f"Unknown [{self.connector.exchange_manager.exchange_name}] order type, order: {fixed}" ) # stop loss and take profits are not tagged as such by ccxt, force it fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = updated_type fixed[trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value] = trigger_above else: self.logger.error( f"Unknown [{self.connector.exchange_manager.exchange_name}] order: stop order " f"with no trigger price, order: {fixed}" ) return fixed def fix_trades(self, raw, **kwargs): raw = super().fix_trades(raw, **kwargs) for trade in raw: trade[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CLOSED.value return raw def parse_position(self, fixed, force_empty=False, **kwargs): try: return super().parse_position(fixed, force_empty=force_empty, **kwargs) except decimal.InvalidOperation: # on binance, positions might be invalid (ex: LUNAUSD_PERP as None contact size) return None def parse_leverage(self, fixed, **kwargs): parsed = super().parse_leverage(fixed, **kwargs) # on binance fixed is a parsed position parsed[trading_enums.ExchangeConstantsLeveragePropertyColumns.LEVERAGE.value] = \ fixed[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value] return parsed def parse_funding_rate(self, fixed, from_ticker=False, **kwargs): """ Binance last funding time is not provided To obtain the last_funding_time : => timestamp(next_funding_time) - timestamp(BINANCE_DEFAULT_FUNDING_TIME) """ if from_ticker: # no funding info in ticker return {} else: funding_dict = super().parse_funding_rate(fixed, from_ticker=from_ticker, **kwargs) funding_next_timestamp = float( funding_dict.get(trading_enums.ExchangeConstantsFundingColumns.NEXT_FUNDING_TIME.value, 0) ) # patch LAST_FUNDING_TIME in tentacle funding_dict.update({ trading_enums.ExchangeConstantsFundingColumns.LAST_FUNDING_TIME.value: max(funding_next_timestamp - self.BINANCE_DEFAULT_FUNDING_TIME, 0) }) return funding_dict def _filter_positions(positions): return [ position for position in positions if position is not None ] ================================================ FILE: Trading/Exchange/binance/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Binance"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/binance/resources/binance.md ================================================ Binance is a RestExchange adaptation for Binance exchange using the REST API. ================================================ FILE: Trading/Exchange/binance/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/binance/tests/test_sandbox.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import os import pytest import octobot_commons.tests as commons_tests import octobot_commons.constants as commons_constants import octobot_trading.util.test_tools.spot_rest_exchange_test_tools as spot_rest_exchange_test_tools import octobot_commons.configuration as configuration from ...binance import Binance # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def _test_spot_rest(): config = commons_tests.load_test_config() config[commons_constants.CONFIG_EXCHANGES][Binance.get_name()] = { commons_constants.CONFIG_EXCHANGE_KEY: configuration.encrypt( os.getenv(f"{Binance.get_name()}_API_KEY".upper())).decode(), commons_constants.CONFIG_EXCHANGE_SECRET: configuration.encrypt( os.getenv(f"{Binance.get_name()}_API_SECRET".upper())).decode() } config[commons_constants.CONFIG_TRADER][commons_constants.CONFIG_ENABLED_OPTION] = True config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_ENABLED_OPTION] = False test_tools = spot_rest_exchange_test_tools.SpotRestExchangeTests(config=config, exchange_name=Binance.get_name()) test_tools.expected_crypto_in_balance = ["BNB", "BTC", "BUSD", "ETH", "LTC", "TRX", "USDT", "XRP"] await test_tools.initialize() await test_tools.run(symbol="BTC/USDT") await test_tools.stop() await test_tools.test_all_callback_triggered() ================================================ FILE: Trading/Exchange/binance_websocket_feed/__init__.py ================================================ from .binance_websocket import BinanceCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/binance_websocket_feed/binance_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import octobot_commons.constants as commons_constants import tentacles.Trading.Exchange.binance.binance_exchange as binance_exchange class BinanceCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return binance_exchange.Binance.get_name() def get_adapter_class(self, adapter_class): return binance_exchange.BinanceCCXTAdapter ================================================ FILE: Trading/Exchange/binance_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BinanceCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/binance_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/binance_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...binance_websocket_feed import BinanceCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() async with websocket_test_tools.ws_exchange_manager(config, BinanceCryptofeedWebsocketConnector.get_name()) \ as exchange_manager_instance: await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=BinanceCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/BTC", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_MINUTE, commons_enums.TimeFrames.ONE_HOUR, commons_enums.TimeFrames.FOUR_HOURS], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.KLINE_CHANNEL.value, channels_name.OctoBotTradingChannelsName.OHLCV_CHANNEL.value, channels_name.OctoBotTradingChannelsName.TICKER_CHANNEL.value, }, time_before_assert=75 ) ================================================ FILE: Trading/Exchange/binanceus/__init__.py ================================================ from .binanceus_exchange import BinanceUS ================================================ FILE: Trading/Exchange/binanceus/binanceus_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.binance as binance_tentacle import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants class BinanceUS(binance_tentacle.Binance): # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request # not supported or need custom mechanics with batch orders trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, # unsupported on binance.us, only stop limit orders are supported https://docs.binance.us/#create-new-order-trade trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } @classmethod def get_name(cls): return 'binanceus' @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, ] @staticmethod def get_default_reference_market(exchange_name: str) -> str: return "USDT" def get_additional_connector_config(self): config = super().get_additional_connector_config() # override to fix ccxt values config[ccxt_constants.CCXT_FEES] = { 'trading': { 'tierBased': True, 'percentage': True, # ccxt replaced values # 'taker': float('0.001'), # 0.1% trading fee, zero fees for all trading pairs before November 1. # 'maker': float('0.001'), # 0.1% trading fee, zero fees for all trading pairs before November 1. # 03/03/2025 values https://www.binance.us/fees 'taker': float('0.006'), # 0.600% 'maker': float('0.004'), # 0.400% }, } return config async def get_account_id(self, **kwargs: dict) -> str: # not available on binance.us # see https://docs.binance.us/#get-user-account-information-user_data # vs "uid" in regular binance https://binance-docs.github.io/apidocs/spot/en/#spot-account-endpoints return trading_constants.DEFAULT_ACCOUNT_ID ================================================ FILE: Trading/Exchange/binanceus/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BinanceUS"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/binanceus/resources/BinanceUS.md ================================================ BinanceUS is a RestExchange adaptation for Binance US exchange using the REST API. ================================================ FILE: Trading/Exchange/binanceus/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/binanceus_websocket_feed/__init__.py ================================================ from .binanceus_websocket import BinanceUSCCXTFeedConnector ================================================ FILE: Trading/Exchange/binanceus_websocket_feed/binanceus_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.binanceus.binanceus_exchange as binanceus_exchange import tentacles.Trading.Exchange.binance_websocket_feed.binance_websocket as binance_websocket class BinanceUSCCXTFeedConnector(binance_websocket.BinanceCCXTWebsocketConnector): @classmethod def get_name(cls): return binanceus_exchange.BinanceUS.get_name() ================================================ FILE: Trading/Exchange/binanceus_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BinanceUSCCXTFeedConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/binanceus_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/binanceus_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...binanceus_websocket_feed import BinanceUSWebsocketFeedConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() async with websocket_test_tools.ws_exchange_manager(config, BinanceUSWebsocketFeedConnector.get_name()) \ as exchange_manager_instance: await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=BinanceUSWebsocketFeedConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/BTC", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value }, time_before_assert=20 ) ================================================ FILE: Trading/Exchange/bingx/__init__.py ================================================ from .bingx_exchange import Bingx ================================================ FILE: Trading/Exchange/bingx/bingx_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.exchanges.connectors.ccxt.ccxt_connector as ccxt_connector import octobot_trading.enums as trading_enums class BingxConnector(ccxt_connector.CCXTConnector): def _create_client(self, force_unauth=False): super()._create_client(force_unauth=force_unauth) # bingx v1 spotV1PublicGetMarketKline randomly errors when fetching candles: force V2 self.client.spotV1PublicGetMarketKline = self.client.spotV2PublicGetMarketKline class Bingx(exchanges.RestExchange): FIX_MARKET_STATUS = True DEFAULT_CONNECTOR_CLASS = BingxConnector # TODO remove this when ccxt updates to spotV2PublicGetMarketKline # text content of errors due to orders not found errors EXCHANGE_ORDER_NOT_FOUND_ERRORS: typing.List[typing.Iterable[str]] = [ # 'bingx {"code":100404,"msg":" order not exist","debugMsg":""}' ("order not exist",), # bingx {"code":100404,"msg":"the order you want to cancel is FILLED or CANCELLED already, or is not a valid # order id ,please verify","debugMsg":""} ("the order you want to cancel is filled or cancelled already", ), # bingx {"code":100404,"msg":"the order is FILLED or CANCELLED already before, or is not a valid # order id ,please verify","debugMsg":""} ("the order is filled or cancelled already before", ), ] # text content of errors due to unhandled authentication issues EXCHANGE_AUTHENTICATION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'bingx {'code': '100413', 'msg': 'Incorrect apiKey', 'timestamp': '1725195218082'}' ("incorrect apikey",), ] # text content of errors due to api key permissions issues EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'bingx {"code":100004,"msg":"Permission denied as the API key was created without the permission, # this api need Spot Trading permission, please config it in https://bingx.com/en/account/api"' ("permission denied", "trading permission"), ] # text content of errors due to an order that can't be cancelled on exchange (because filled or already cancelled) EXCHANGE_ORDER_UNCANCELLABLE_ERRORS: typing.List[typing.Iterable[str]] = [ ('the order is filled or cancelled', ''), ('order not exist', '') ] # text content of errors due to unhandled IP white list issues EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # "PermissionDenied("bingx {"code":100419,"msg":"your current request IP is xx.xx.xx.xxx does not match IP # whitelist , please go to https://bingx.com/en/account/api/ to verify the ip you have set", # "timestamp":1739291708037}")" ("not match ip whitelist",), ] # Set True when get_open_order() can return outdated orders (cancelled or not yet created) CAN_HAVE_DELAYED_CANCELLED_ORDERS = True # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request # not supported or need custom mechanics with batch orders trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } def get_adapter_class(self): return BingxCCXTAdapter @classmethod def get_name(cls) -> str: return 'bingx' async def get_account_id(self, **kwargs: dict) -> str: with self.connector.error_describer(): resp = await self.connector.client.accountV1PrivateGetUid() return resp["data"]["uid"] def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: # unknown (05/06/2025) return super().get_max_orders_count(symbol, order_type) async def get_my_recent_trades(self, symbol=None, since=None, limit=None, **kwargs): # On SPOT Bingx, account recent trades is available under fetch_closed_orders if self.exchange_manager.is_future: return await super().get_my_recent_trades(symbol=symbol, since=since, limit=limit, **kwargs) return await super().get_closed_orders(symbol=symbol, since=since, limit=limit, **kwargs) def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: signature_identifier = "signature=" return bool( url and signature_identifier in url ) class BingxCCXTAdapter(exchanges.CCXTAdapter): def _update_stop_order_or_trade_type_and_price(self, order_or_trade: dict): info = order_or_trade.get(ccxt_constants.CCXT_INFO, {}) if stop_price := order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.STOP_LOSS_PRICE.value): # from https://bingx-api.github.io/docs/#/en-us/spot/trade-api.html#Current%20Open%20Orders order_creation_price = float( info.get("price") or order_or_trade.get( trading_enums.ExchangeConstantsOrderColumns.PRICE.value ) ) is_selling = ( order_or_trade[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] == trading_enums.TradeOrderSide.SELL.value ) stop_price = float(stop_price) # use stop price as order price to parse it properly order_or_trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = stop_price # type is TAKE_STOP_LIMIT (not unified) if order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.TYPE.value) == "take_stop_limit": # unsupported: no way to figure out if this order is a stop loss or a take profit # (trigger above or bellow) order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = ( trading_enums.TradeOrderType.UNSUPPORTED.value) self.logger.info(f"Unsupported order fetched: {order_or_trade}") else: if stop_price <= order_creation_price: trigger_above = False if is_selling: order_type = trading_enums.TradeOrderType.STOP_LOSS.value order_or_trade[trading_enums.ExchangeConstantsOrderColumns.STOP_PRICE.value] = stop_price else: order_type = trading_enums.TradeOrderType.LIMIT.value else: trigger_above = True if is_selling: order_type = trading_enums.TradeOrderType.LIMIT.value else: order_type = trading_enums.TradeOrderType.STOP_LOSS.value order_or_trade[trading_enums.ExchangeConstantsOrderColumns.STOP_PRICE.value] = stop_price order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value] = trigger_above order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = order_type def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) self._update_stop_order_or_trade_type_and_price(fixed) try: info = fixed[ccxt_constants.CCXT_INFO] fixed[trading_enums.ExchangeConstantsOrderColumns.EXCHANGE_ID.value] = info["orderId"] except KeyError: pass return fixed def fix_trades(self, raw, **kwargs): fixed = super().fix_trades(raw, **kwargs) for trade in fixed: self._update_stop_order_or_trade_type_and_price(trade) return fixed def fix_market_status(self, raw, remove_price_limits=False, **kwargs): fixed = super().fix_market_status(raw, remove_price_limits=remove_price_limits, **kwargs) if not fixed: return fixed # bingx min and max quantity should be ignored # https://bingx-api.github.io/docs/#/en-us/spot/market-api.html#Spot%20trading%20symbols limits = fixed[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS.value] limits[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT.value][ trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT_MIN.value ] = 0 limits[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT.value][ trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_AMOUNT_MAX.value ] = None return fixed ================================================ FILE: Trading/Exchange/bingx/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bingx"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bingx/resources/bingx.md ================================================ Bingx is a RestExchange adaptation for Bingx exchange using the REST API. ================================================ FILE: Trading/Exchange/bingx/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bingx_websocket_feed/__init__.py ================================================ from .bingx_websocket import BingxCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/bingx_websocket_feed/bingx_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.bingx.bingx_exchange as bingx_exchange class BingxCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: False, Feeds.CANDLE: True, } @classmethod def get_name(cls): # disabled as too unstable for now (using ccxt 4.1.82) # => feeds are disconnecting and not reconnecting return f"{bingx_exchange.Bingx.get_name()}-disabled" def get_adapter_class(self, adapter_class): return bingx_exchange.BingxCCXTAdapter ================================================ FILE: Trading/Exchange/bingx_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BingxCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitfinex/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .bitfinex_exchange import Bitfinex ================================================ FILE: Trading/Exchange/bitfinex/bitfinex_exchange.py ================================================ # Drakkar-Software OctoBot-Private-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_commons.enums import octobot_commons.constants import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums class Bitfinex(exchanges.RestExchange): # bitfinex only supports 1, 25 and 100 size # https://docs.bitfinex.com/reference#rest-public-book SUPPORTED_ORDER_BOOK_LIMITS = [1, 25, 100] DEFAULT_ORDER_BOOK_LIMIT = 25 DEFAULT_CANDLE_LIMIT = 500 @classmethod def get_name(cls): return 'bitfinex' def get_adapter_class(self): return BitfinexCCXTAdapter async def get_symbol_prices(self, symbol, time_frame, limit: int = 500, **kwargs: dict): if "since" not in kwargs: # prevent bitfinex from getting candles from 2014 tf_seconds = octobot_commons.enums.TimeFramesMinutes[time_frame] * \ octobot_commons.constants.MINUTE_TO_SECONDS kwargs["since"] = (self.get_exchange_current_time() - tf_seconds * limit) \ * octobot_commons.constants.MSECONDS_TO_SECONDS return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) async def get_kline_price(self, symbol: str, time_frame: octobot_commons.enums.TimeFrames, **kwargs: dict) -> typing.Optional[list]: return (await self.get_symbol_prices(symbol, time_frame, limit=1))[-1:] async def get_order_book(self, symbol, limit=DEFAULT_ORDER_BOOK_LIMIT, **kwargs): if limit is None or limit not in self.SUPPORTED_ORDER_BOOK_LIMITS: self.logger.debug(f"Trying to get_order_book with limit not {self.SUPPORTED_ORDER_BOOK_LIMITS} : ({limit})") limit = self.DEFAULT_ORDER_BOOK_LIMIT return await super().get_recent_trades(symbol=symbol, limit=limit, **kwargs) class BitfinexCCXTAdapter(exchanges.CCXTAdapter): def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Exchange/bitfinex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bitfinex"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitfinex_websocket_feed/__init__.py ================================================ from .bitfinex_websocket import BitfinexCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/bitfinex_websocket_feed/bitfinex_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.bitfinex.bitfinex_exchange as bitfinex_exchange class BitfinexCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: Feeds.UNSUPPORTED.value, # ohlcv is getting closed candles after new ones, this it not yet supported Feeds.TICKER: True, Feeds.CANDLE: Feeds.UNSUPPORTED.value, # ohlcv is getting closed candles after new ones, this it not yet supported } @classmethod def get_name(cls): return bitfinex_exchange.Bitfinex.get_name() ================================================ FILE: Trading/Exchange/bitfinex_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BitfinexCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitfinex_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bitfinex_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.exchanges_test_tools as exchanges_test_tools import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...bitfinex_websocket_feed import BitfinexCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() exchange_manager_instance = await exchanges_test_tools.create_test_exchange_manager( config=config, exchange_name=BitfinexCryptofeedWebsocketConnector.get_name()) await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=BitfinexCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_MINUTE, commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.RECENT_TRADES_CHANNEL.value, channels_name.OctoBotTradingChannelsName.TICKER_CHANNEL.value, }, time_before_assert=20 ) await exchanges_test_tools.stop_test_exchange_manager(exchange_manager_instance) ================================================ FILE: Trading/Exchange/bitget/__init__.py ================================================ from .bitget_exchange import Bitget ================================================ FILE: Trading/Exchange/bitget/bitget_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.enums as trading_enums class Bitget(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True @classmethod def get_name(cls): return 'bitget' def get_adapter_class(self): return BitgetCCXTAdapter def get_additional_connector_config(self): # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders return { ccxt_constants.CCXT_OPTIONS: { "createMarketBuyOrderRequiresPrice": False # disable quote conversion } } class BitgetCCXTAdapter(exchanges.CCXTAdapter): def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) self.adapt_amount_from_filled_or_cost(fixed) return fixed def fix_trades(self, raw, **kwargs): raw = super().fix_trades(raw, **kwargs) for trade in raw: fee = trade[trading_enums.ExchangeConstantsOrderColumns.FEE.value] if trading_enums.FeePropertyColumns.CURRENCY.value not in fee: fee[trading_enums.FeePropertyColumns.CURRENCY.value] = fee.get("code") return raw ================================================ FILE: Trading/Exchange/bitget/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bitget"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitget/resources/bitget.md ================================================ Bitget is a RestExchange adaptation for Bitget exchange using the REST API. ================================================ FILE: Trading/Exchange/bitget/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bitget_websocket_feed/__init__.py ================================================ from .bitget_websocket import BitgetCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/bitget_websocket_feed/bitget_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.bitget.bitget_exchange as bitget_exchange class BitgetCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return bitget_exchange.Bitget.get_name() def get_adapter_class(self, adapter_class): return bitget_exchange.BitgetCCXTAdapter ================================================ FILE: Trading/Exchange/bitget_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BitgetCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitget_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bithumb/__init__.py ================================================ from .bithumb_exchange import Bithumb ================================================ FILE: Trading/Exchange/bithumb/bithumb_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class Bithumb(exchanges.RestExchange): DESCRIPTION = "" @classmethod def get_name(cls): return 'bithumb' async def get_symbol_prices(self, symbol, time_frame, limit: int = None, **kwargs: dict): # ohlcv limit is not working as expected, limit is doing [:-limit] but we want [-limit:] candles = await super().get_symbol_prices(symbol=symbol, time_frame=time_frame, limit=limit, **kwargs) if limit: return candles[-limit:] return candles ================================================ FILE: Trading/Exchange/bithumb/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bithumb"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bithumb/resources/bithumb.md ================================================ Bithumb is a basic RestExchange adaptation for Bithumb exchange. ================================================ FILE: Trading/Exchange/bithumb/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bitmart/__init__.py ================================================ from .bitmart_exchange import BitMart ================================================ FILE: Trading/Exchange/bitmart/bitmart_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import ccxt.async_support import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants class BitMartConnector(exchanges.CCXTConnector): def _client_factory( self, force_unauth, keys_adapter: typing.Callable[[exchanges.ExchangeCredentialsData], exchanges.ExchangeCredentialsData]=None ) -> tuple: client, is_authenticated = super()._client_factory(force_unauth, keys_adapter=self._keys_adapter) if client: client.handle_errors = self._patched_handle_errors_factory(client) return client, is_authenticated def _patched_handle_errors_factory(self, client: ccxt.async_support.Exchange): self = client # set self to the client to use the client methods def _patched_handle_errors(code: int, reason: str, url: str, method: str, headers: dict, body: str, response, requestHeaders, requestBody): # temporary patch waiting for CCXT fix (issue in ccxt 4.5.28) if response is None: return None # # spot # # {"message":"Bad Request [to is empty]","code":50000,"trace":"f9d46e1b-4edb-4d07-a06e-4895fb2fc8fc","data":{}} # {"message":"Bad Request [from is empty]","code":50000,"trace":"579986f7-c93a-4559-926b-06ba9fa79d76","data":{}} # {"message":"Kline size over 500","code":50004,"trace":"d625caa8-e8ca-4bd2-b77c-958776965819","data":{}} # {"message":"Balance not enough","code":50020,"trace":"7c709d6a-3292-462c-98c5-32362540aeef","data":{}} # {"code":40012,"message":"You contract account available balance not enough.","trace":"..."} # # contract # # {"errno":"OK","message":"INVALID_PARAMETER","code":49998,"trace":"eb5ebb54-23cd-4de2-9064-e090b6c3b2e3","data":null} # message = self.safe_string_lower(response, 'message') # PATCH isErrorMessage = (message is not None) and (message != 'ok') and (message != 'success') errorCode = self.safe_string(response, 'code') isErrorCode = (errorCode is not None) and (errorCode != '1000') if isErrorCode or isErrorMessage: feedback = self.id + ' ' + body self.throw_exactly_matched_exception(self.exceptions['exact'], message, feedback) self.throw_broadly_matched_exception(self.exceptions['broad'], message, feedback) self.throw_exactly_matched_exception(self.exceptions['exact'], errorCode, feedback) self.throw_broadly_matched_exception(self.exceptions['broad'], errorCode, feedback) raise ccxt.ExchangeError(feedback) # unknown message return None return _patched_handle_errors def _keys_adapter(self, creds: exchanges.ExchangeCredentialsData) -> exchanges.ExchangeCredentialsData: # use password as uid creds.uid = creds.password creds.password = None return creds class BitMart(exchanges.RestExchange): FIX_MARKET_STATUS = True DEFAULT_CONNECTOR_CLASS = BitMartConnector REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True # broken: need v4 endpoint required, 10/10/25 ccxt still doesn't have it # bitmart {"msg":"This endpoint has been deprecated. Please refer to the document: # https://developer-pro.bitmart.com/en/spot/#update-plan","code":30031} SUPPORT_FETCHING_CANCELLED_ORDERS = False ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server @classmethod def get_name(cls): return 'bitmart' def get_adapter_class(self): return BitMartCCXTAdapter def get_additional_connector_config(self): # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders return { ccxt_constants.CCXT_OPTIONS: { "createMarketBuyOrderRequiresPrice": False # disable quote conversion } } async def get_account_id(self, **kwargs: dict) -> str: # not available on bitmart return trading_constants.DEFAULT_ACCOUNT_ID class BitMartCCXTAdapter(exchanges.CCXTAdapter): def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) self.adapt_amount_from_filled_or_cost(fixed) if ( fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] == trading_enums.TradeOrderType.MARKET.value and fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == trading_enums.OrderStatus.CANCELED.value and fixed[trading_enums.ExchangeConstantsOrderColumns.FILLED.value] ): # consider as filled & closed (Bitmart is sometimes tagging filled market orders as "canceled": ignore it) fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CLOSED.value return fixed ================================================ FILE: Trading/Exchange/bitmart/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BitMart"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitmart/resources/bitmart.md ================================================ BitMart is a RestExchange adaptation for BitMart exchange using the REST API. ================================================ FILE: Trading/Exchange/bitmart_websocket_feed/__init__.py ================================================ from .bitmart_websocket import BitMartCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/bitmart_websocket_feed/bitmart_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.bitmart.bitmart_exchange as bitmart_exchange class BitMartCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return bitmart_exchange.BitMart.get_name() def get_adapter_class(self, adapter_class): return bitmart_exchange.BitMartCCXTAdapter ================================================ FILE: Trading/Exchange/bitmart_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BitMartCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitmex/__init__.py ================================================ # Drakkar-Software OctoBot-Private-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .bitmex_exchange import * ================================================ FILE: Trading/Exchange/bitmex/bitmex_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums class Bitmex(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = False # todo fix precision price but not amount ? todo check BUY_STR = "Buy" SELL_STR = "Sell" MARK_PRICE_IN_TICKER = True FUNDING_IN_TICKER = True @classmethod def get_name(cls): return 'bitmex' @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] ================================================ FILE: Trading/Exchange/bitmex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bitmex"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitmex/resources/bitmex.md ================================================ Bitmex is a basic RestExchange adaptation for Bitmex exchange. ================================================ FILE: Trading/Exchange/bitmex/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bitso/__init__.py ================================================ from .bitso_exchange import Bitso ================================================ FILE: Trading/Exchange/bitso/bitso_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class Bitso(exchanges.RestExchange): DESCRIPTION = "" DEFAULT_MAX_LIMIT = 500 FIX_MARKET_STATUS = True @classmethod def get_name(cls): return 'bitso' ================================================ FILE: Trading/Exchange/bitso/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bitso"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitso/resources/bitso.md ================================================ Bitso is a basic RestExchange adaptation for Bitso exchange. ================================================ FILE: Trading/Exchange/bitso/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bitstamp/__init__.py ================================================ from .bitstamp_exchange import Bitstamp ================================================ FILE: Trading/Exchange/bitstamp/bitstamp_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class Bitstamp(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True DEFAULT_MAX_LIMIT = 500 @classmethod def get_name(cls): return 'bitstamp' async def get_symbol_prices(self, symbol, time_frame, limit: int = None, **kwargs: dict): # ohlcv without limit is not supported, replaced by a default max limit if limit is None: limit = self.DEFAULT_MAX_LIMIT return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) ================================================ FILE: Trading/Exchange/bitstamp/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Bitstamp"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bitstamp/resources/bitstamp.md ================================================ Bitstamp is a basic RestExchange adaptation for Bitstamp exchange. ================================================ FILE: Trading/Exchange/bitstamp/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bybit/__init__.py ================================================ from .bybit_exchange import Bybit ================================================ FILE: Trading/Exchange/bybit/bybit_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import typing import ccxt import octobot_commons.constants as commons_constants import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.constants as constants import octobot_trading.personal_data as trading_personal_data import octobot_trading.errors class Bybit(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True # Bybit default take profits are market orders # note: use BUY_MARKET and SELL_MARKET since in reality those are conditional market orders, which behave the same # way as limit order but with higher fees _BYBIT_BUNDLED_ORDERS = [trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.BUY_MARKET, trading_enums.TraderOrderType.SELL_MARKET] # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on futures trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: { trading_enums.TraderOrderType.BUY_MARKET: _BYBIT_BUNDLED_ORDERS, trading_enums.TraderOrderType.SELL_MARKET: _BYBIT_BUNDLED_ORDERS, trading_enums.TraderOrderType.BUY_LIMIT: _BYBIT_BUNDLED_ORDERS, trading_enums.TraderOrderType.SELL_LIMIT: _BYBIT_BUNDLED_ORDERS, }, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } MARK_PRICE_IN_TICKER = True FUNDING_IN_TICKER = True # set True when get_positions() is not returning empty positions and should use get_position() instead REQUIRES_SYMBOL_FOR_EMPTY_POSITION = True REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees EXPECT_POSSIBLE_ORDER_NOT_FOUND_DURING_ORDER_CREATION = True # set True when get_order() can return None # (order not found) when orders are instantly filled on exchange and are not fully processed on the exchange side. # Set True when get_open_order() can return outdated orders (cancelled or not yet created) CAN_HAVE_DELAYED_CANCELLED_ORDERS = True ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server BUY_STR = "Buy" SELL_STR = "Sell" LONG_STR = BUY_STR SHORT_STR = SELL_STR # Order category. 0:normal order by default; 1:TP/SL order, Required for TP/SL order. ORDER_CATEGORY = "orderCategory" STOP_ORDERS_FILTER = "stop" SPOT_STOP_ORDERS_FILTER = "StopOrder" ORDER_FILTER = "orderFilter" def __init__( self, config, exchange_manager, exchange_config_by_exchange: typing.Optional[dict[str, dict]], connector_class=None ): super().__init__(config, exchange_manager, exchange_config_by_exchange, connector_class=connector_class) self.order_quantity_by_amount = {} self.order_quantity_by_id = {} def get_additional_connector_config(self): connector_config = { ccxt_constants.CCXT_OPTIONS: { "recvWindow": 60000, # default is 5000, avoid time related issues } } if not self.exchange_manager.is_future: # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders connector_config[ccxt_constants.CCXT_OPTIONS][ "createMarketBuyOrderRequiresPrice" ] = False # disable quote conversion return connector_config def get_adapter_class(self): return BybitCCXTAdapter @classmethod def get_name(cls) -> str: return 'bybit' @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] async def initialize_impl(self): await super().initialize_impl() # ensure the authenticated account is not a unified trading account as it is not fully supported await self._check_unified_account() async def _check_unified_account(self): if self.connector.client and not self.exchange_manager.exchange_only: try: self.connector.client.check_required_credentials() enable_unified_margin, enable_unified_account = await self.connector.client.is_unified_enabled() if enable_unified_margin or enable_unified_account: raise octobot_trading.errors.NotSupported( "Ignoring Bybit exchange: " "Bybit unified trading accounts are not yet fully supported. To trade on Bybit, please use a " "standard account. You can easily switch between unified and standard using subaccounts. " "Transferring funds between subaccounts is free and instant." ) except ccxt.AuthenticationError: # unauthenticated pass async def get_open_orders(self, symbol: str = None, since: int = None, limit: int = None, **kwargs: dict) -> list: orders = await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs) if not self.exchange_manager.is_future: kwargs = kwargs or {} # include stop orders kwargs[self.ORDER_FILTER] = self.SPOT_STOP_ORDERS_FILTER orders += await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs) return orders async def get_order( self, exchange_order_id: str, symbol: typing.Optional[str] = None, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs: dict ) -> dict: # regular get order is not supported return await self.get_order_from_open_and_closed_orders(exchange_order_id, symbol=symbol, **kwargs) async def cancel_order( self, exchange_order_id: str, symbol: str, order_type: trading_enums.TraderOrderType, **kwargs: dict ) -> trading_enums.OrderStatus: kwargs = kwargs or {} if trading_personal_data.is_stop_order(order_type): kwargs[self.ORDER_FILTER] = self.SPOT_STOP_ORDERS_FILTER return await super().cancel_order( exchange_order_id, symbol, order_type, **kwargs ) async def create_order(self, order_type: trading_enums.TraderOrderType, symbol: str, quantity: decimal.Decimal, price: decimal.Decimal = None, stop_price: decimal.Decimal = None, side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, reduce_only: bool = False, params: dict = None) -> typing.Optional[dict]: if not self.exchange_manager.is_future: # should be replacable by ENABLE_SPOT_BUY_MARKET_WITH_COST = True => check when upgrading to unified if order_type is trading_enums.TraderOrderType.BUY_MARKET: # on Bybit, market orders are in quote currency (YYY in XYZ/YYY) used_price = price or current_price if not used_price: raise octobot_trading.errors.NotSupported(f"{self.get_name()} requires a price parameter to create " f"market orders as quantity is in quote currency") origin_quantity = quantity quantity = quantity * used_price self.order_quantity_by_amount[float(quantity)] = float(origin_quantity) return await super().create_order(order_type, symbol, quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params) def _get_stop_trigger_direction(self, side): if side == trading_enums.TradeOrderSide.SELL.value: return "bellow" return "above" async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: # todo make sure this still works params = params or {} params["triggerPrice"] = price if self.exchange_manager.is_future: # BybitCCXTAdapter.BYBIT_TRIGGER_ABOVE_KEY required on future stop orders params[BybitCCXTAdapter.BYBIT_TRIGGER_ABOVE_KEY] = self._get_stop_trigger_direction(side) # else: # params[self.ORDER_CATEGORY] = 1 order = self.connector.adapter.adapt_order( await self.connector.client.create_order( symbol, trading_enums.TradeOrderType.MARKET.value, side, quantity, params=params ), symbol=symbol, quantity=quantity ) return order async def _edit_order(self, exchange_order_id: str, order_type: trading_enums.TraderOrderType, symbol: str, quantity: float, price: float, stop_price: float = None, side: str = None, current_price: float = None, params: dict = None): params = params or {} if trading_personal_data.is_stop_order(order_type): params["stop_order_id"] = exchange_order_id if stop_price is not None: # params["stop_px"] = stop_price # params["stop_loss"] = stop_price params["triggerPrice"] = str(stop_price) return await super()._edit_order(exchange_order_id, order_type, symbol, quantity=quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, params=params) async def _verify_order(self, created_order, order_type, symbol, price, quantity, side, get_order_params=None): return await super()._verify_order(created_order, order_type, symbol, price, quantity, side, get_order_params=get_order_params) async def set_symbol_partial_take_profit_stop_loss(self, symbol: str, inverse: bool, tp_sl_mode: trading_enums.TakeProfitStopLossMode): # /contract/v3/private/position/switch-tpsl-mode # from https://bybit-exchange.github.io/docs/derivativesV3/contract/#t-dv_switchpositionmode params = { "symbol": self.connector.client.market(symbol)['id'], "tpSlMode": tp_sl_mode.value } try: await self.connector.client.privatePostContractV3PrivatePositionSwitchTpslMode(params) except ccxt.ExchangeError as e: if "same tp sl mode1" in str(e): # can't fetch the tp sl mode1 value return raise def get_order_additional_params(self, order) -> dict: params = {} if self.exchange_manager.is_future: contract = self.exchange_manager.exchange.get_pair_future_contract(order.symbol) params["positionIdx"] = self._get_position_idx(contract) params["reduceOnly"] = order.reduce_only return params def _get_margin_type_query_params(self, symbol, **kwargs): if not self.exchange_manager.exchange.has_pair_future_contract(symbol): raise KeyError(f"{symbol} contract unavailable") else: contract = self.exchange_manager.exchange.get_pair_future_contract(symbol) kwargs = kwargs or {} kwargs[ccxt_enums.ExchangePositionCCXTColumns.LEVERAGE.value] = float(contract.current_leverage) return kwargs async def set_symbol_margin_type(self, symbol: str, isolated: bool, **kwargs: dict): kwargs = self._get_margin_type_query_params(symbol, **kwargs) await super().set_symbol_margin_type(symbol, isolated, **kwargs) def get_bundled_order_parameters(self, order, stop_loss_price=None, take_profit_price=None) -> dict: """ Returns the updated params when this exchange supports orders created upon other orders fill (ex: a stop loss created at the same time as a buy order) :param order: the initial order :param stop_loss_price: the bundled order stopLoss price :param take_profit_price: the bundled order takeProfit price :return: A dict with the necessary parameters to create the bundled order on exchange alongside the base order in one request """ params = {} if stop_loss_price is not None: params["stopLoss"] = str(stop_loss_price) if take_profit_price is not None: params["takeProfit"] = str(take_profit_price) return params def _get_position_idx(self, contract): # "position_idx" has to be set when trading futures # from https://bybit-exchange.github.io/docs/inverse/#t-myposition # Position idx, used to identify positions in different position modes: # 0-One-Way Mode # 1-Buy side of both side mode # 2-Sell side of both side mode if contract.is_one_way_position_mode(): return 0 else: raise NotImplementedError( f"Hedge mode is not implemented yet. Please switch to One-Way position mode from the Bybit " f"trading interface preferences of {contract.pair}" ) # TODO # if Buy side of both side mode: # return 1 # else Buy side of both side mode: # return 2 class BybitCCXTAdapter(exchanges.CCXTAdapter): # Position BYBIT_BANKRUPTCY_PRICE = "bustPrice" BYBIT_CLOSING_FEE = "occClosingFee" BYBIT_MODE = "positionIdx" BYBIT_TRADE_MODE = "tradeMode" BYBIT_REALIZED_PNL = "RealisedPnl" BYBIT_ONE_WAY = "MergedSingle" BYBIT_ONE_WAY_DIGIT = "0" BYBIT_HEDGE = "BothSide" BYBIT_HEDGE_DIGITS = ["1", "2"] # Funding BYBIT_DEFAULT_FUNDING_TIME = 8 * commons_constants.HOURS_TO_SECONDS # Orders BYBIT_REDUCE_ONLY = "reduceOnly" BYBIT_TRIGGER_ABOVE_KEY = "triggerDirection" BYBIT_TRIGGER_ABOVE_VALUE = "1" # Trades EXEC_TYPE = "execType" TRADE_TYPE = "Trade" def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) order_info = raw[trading_enums.ExchangeConstantsOrderColumns.INFO.value] # parse reduce_only if present fixed[trading_enums.ExchangeConstantsOrderColumns.REDUCE_ONLY.value] = \ order_info.get(self.BYBIT_REDUCE_ONLY, False) if tigger_above := order_info.get(trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value): fixed[trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value] = \ tigger_above == self.BYBIT_TRIGGER_ABOVE_VALUE status = fixed.get(trading_enums.ExchangeConstantsOrderColumns.STATUS.value) if status == 'ORDER_NEW': fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.OPEN.value if status == 'ORDER_CANCELED': fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CANCELED.value if status == 'PARTIALLY_FILLED_CANCELED': fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.FILLED.value self._adapt_order_type(fixed) if not self.connector.exchange_manager.is_future: try: if fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] \ == trading_enums.TradeOrderType.MARKET.value and \ fixed[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] \ == trading_enums.TradeOrderSide.BUY.value: try: quantity = self.connector.exchange_manager.exchange.order_quantity_by_amount[ kwargs.get("quantity", fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.AMOUNT.value)) ] self.connector.exchange_manager.exchange.order_quantity_by_id[ fixed[ccxt_enums.ExchangeOrderCCXTColumns.ID.value] ] = quantity except KeyError: try: quantity = self.connector.exchange_manager.exchange.order_quantity_by_id[ fixed[ccxt_enums.ExchangeOrderCCXTColumns.ID.value]] except KeyError: amount = fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.AMOUNT.value) price = fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.AVERAGE.value, fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.PRICE.value) ) quantity = amount / (price if price else 1) if fixed[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] is None or \ fixed[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] < quantity * 0.999: # when order status is PARTIALLY_FILLED_CANCELED but is actually filled fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = \ trading_enums.OrderStatus.OPEN.value # convert amount to have the same units as every other exchange fixed[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] = quantity except KeyError: pass return fixed def _adapt_order_type(self, fixed): if fixed.get("triggerPrice", None): if fixed.get("takeProfitPrice", None): # take profit are not tagged as such by ccxt, force it # check take profit first as takeProfitPrice is also set for stop losses fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = \ trading_enums.TradeOrderType.TAKE_PROFIT.value elif fixed.get("stopPrice", None): # stop loss are not tagged as such by ccxt, force it fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = \ trading_enums.TradeOrderType.STOP_LOSS.value else: self.logger.error(f"Unknown [{self.connector.exchange_manager.exchange_name}] trigger order: {fixed}") return fixed def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed def parse_position(self, fixed, **kwargs): try: # todo handle contract value raw_position_info = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.INFO.value) size = decimal.Decimal( str(fixed.get(ccxt_enums.ExchangePositionCCXTColumns.CONTRACTS.value, 0))) # if size == constants.ZERO: # return {} # Don't parse empty position symbol = self.connector.get_pair_from_exchange( fixed[ccxt_enums.ExchangePositionCCXTColumns.SYMBOL.value]) raw_mode = raw_position_info.get(self.BYBIT_MODE) mode = trading_enums.PositionMode.ONE_WAY if raw_mode == self.BYBIT_HEDGE or raw_mode in self.BYBIT_HEDGE_DIGITS: mode = trading_enums.PositionMode.HEDGE trade_mode = raw_position_info.get(self.BYBIT_TRADE_MODE) margin_type = trading_enums.MarginType.ISOLATED if trade_mode == "1" else trading_enums.MarginType.CROSS original_side = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.SIDE.value) side = trading_enums.PositionSide.BOTH # todo when handling cross positions # side = fixed.get(ccxt_enums.ExchangePositionCCXTColumns.SIDE.value, enums.PositionSide.UNKNOWN.value) # position_side = enums.PositionSide.LONG \ # if side == enums.PositionSide.LONG.value else enums.PositionSide.SHORT unrealized_pnl = self.safe_decimal(fixed, ccxt_enums.ExchangePositionCCXTColumns.UNREALISED_PNL.value, constants.ZERO) liquidation_price = self.safe_decimal(fixed, ccxt_enums.ExchangePositionCCXTColumns.LIQUIDATION_PRICE.value, constants.ZERO) entry_price = self.safe_decimal(fixed, ccxt_enums.ExchangePositionCCXTColumns.ENTRY_PRICE.value, constants.ZERO) return { trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value: symbol, trading_enums.ExchangeConstantsPositionColumns.TIMESTAMP.value: self.connector.client.safe_value(fixed, ccxt_enums.ExchangePositionCCXTColumns.TIMESTAMP.value, 0), trading_enums.ExchangeConstantsPositionColumns.SIDE.value: side, trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value: margin_type, trading_enums.ExchangeConstantsPositionColumns.SIZE.value: size if original_side == trading_enums.PositionSide.LONG.value else -size, trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value: self.safe_decimal( fixed, ccxt_enums.ExchangePositionCCXTColumns.INITIAL_MARGIN.value, constants.ZERO ), trading_enums.ExchangeConstantsPositionColumns.NOTIONAL.value: self.safe_decimal( fixed, ccxt_enums.ExchangePositionCCXTColumns.NOTIONAL.value, constants.ZERO ), trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value: self.safe_decimal( fixed, ccxt_enums.ExchangePositionCCXTColumns.LEVERAGE.value, constants.ONE ), trading_enums.ExchangeConstantsPositionColumns.UNREALIZED_PNL.value: unrealized_pnl, trading_enums.ExchangeConstantsPositionColumns.REALISED_PNL.value: self.safe_decimal( fixed, self.BYBIT_REALIZED_PNL, constants.ZERO ), trading_enums.ExchangeConstantsPositionColumns.LIQUIDATION_PRICE.value: liquidation_price, trading_enums.ExchangeConstantsPositionColumns.CLOSING_FEE.value: self.safe_decimal( fixed, self.BYBIT_CLOSING_FEE, constants.ZERO ), trading_enums.ExchangeConstantsPositionColumns.BANKRUPTCY_PRICE.value: self.safe_decimal( fixed, self.BYBIT_BANKRUPTCY_PRICE, constants.ZERO ), trading_enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value: entry_price, trading_enums.ExchangeConstantsPositionColumns.CONTRACT_TYPE.value: self.connector.exchange_manager.exchange.get_contract_type(symbol), trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value: mode, } except KeyError as e: self.logger.error(f"Fail to parse position dict ({e})") return fixed def parse_funding_rate(self, fixed, from_ticker=False, **kwargs): """ Bybit last funding time is not provided To obtain the last_funding_time : => timestamp(next_funding_time) - timestamp(BYBIT_DEFAULT_FUNDING_TIME) """ funding_dict = super().parse_funding_rate(fixed, from_ticker=from_ticker, **kwargs) if from_ticker: if ccxt_constants.CCXT_INFO not in funding_dict: return {} # no data in fixed when coming from ticker funding_dict = fixed[ccxt_constants.CCXT_INFO] funding_next_timestamp = self.get_uniformized_timestamp( float(funding_dict.get(ccxt_enums.ExchangeFundingCCXTColumns.NEXT_FUNDING_TIME.value, 0)) ) funding_rate = decimal.Decimal( str(funding_dict.get(ccxt_enums.ExchangeFundingCCXTColumns.FUNDING_RATE.value, constants.NaN)) ) funding_dict.update({ trading_enums.ExchangeConstantsFundingColumns.LAST_FUNDING_TIME.value: max(funding_next_timestamp - self.BYBIT_DEFAULT_FUNDING_TIME, 0), trading_enums.ExchangeConstantsFundingColumns.FUNDING_RATE.value: funding_rate, trading_enums.ExchangeConstantsFundingColumns.NEXT_FUNDING_TIME.value: funding_next_timestamp, trading_enums.ExchangeConstantsFundingColumns.PREDICTED_FUNDING_RATE.value: funding_rate }) else: funding_next_timestamp = float( funding_dict.get(trading_enums.ExchangeConstantsFundingColumns.NEXT_FUNDING_TIME.value, 0) ) # patch LAST_FUNDING_TIME in tentacle funding_dict.update({ trading_enums.ExchangeConstantsFundingColumns.LAST_FUNDING_TIME.value: max(funding_next_timestamp - self.BYBIT_DEFAULT_FUNDING_TIME, 0) }) return funding_dict def parse_mark_price(self, fixed, from_ticker=False, **kwargs) -> dict: if from_ticker and ccxt_constants.CCXT_INFO in fixed: try: return { trading_enums.ExchangeConstantsMarkPriceColumns.MARK_PRICE.value: fixed[ccxt_constants.CCXT_INFO][trading_enums.ExchangeConstantsMarkPriceColumns.MARK_PRICE.value] } except KeyError: pass return { trading_enums.ExchangeConstantsMarkPriceColumns.MARK_PRICE.value: decimal.Decimal(fixed[ trading_enums.ExchangeConstantsTickersColumns.CLOSE.value]) } def fix_trades(self, raw, **kwargs): if self.connector.exchange_manager.is_future: raw = [ trade for trade in raw if trade[trading_enums.ExchangeConstantsOrderColumns.INFO.value].get( self.EXEC_TYPE, None) == self.TRADE_TYPE # ignore non-trade elements (such as funding) ] return super().fix_trades(raw, **kwargs) ================================================ FILE: Trading/Exchange/bybit/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": [ "Bybit" ], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/bybit/resources/bybit.md ================================================ Bybit is a basic RestExchange adaptation for Bybit exchange. ================================================ FILE: Trading/Exchange/bybit/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/bybit_websocket_feed/__init__.py ================================================ from .bybit_websocket import BybitCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/bybit_websocket_feed/bybit_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.bybit.bybit_exchange as bybit_exchange class BybitCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return bybit_exchange.Bybit.get_name() def get_adapter_class(self, adapter_class): return bybit_exchange.BybitCCXTAdapter ================================================ FILE: Trading/Exchange/bybit_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BybitCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/coinbase/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .coinbase_exchange import Coinbase ================================================ FILE: Trading/Exchange/coinbase/coinbase_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import decimal import ccxt import copy import octobot_trading.errors import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.exchanges.connectors.ccxt.ccxt_connector as ccxt_connector import octobot_trading.exchanges.connectors.ccxt.ccxt_client_util as ccxt_client_util import octobot_trading.personal_data.orders.order_util as order_util import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.symbols as commons_symbols import octobot_commons.logging as logging import octobot_commons.os_util as os_util ALIASED_SYMBOLS = set() # hard code Coinbase base tier fees as long as there is no way to fetch it # https://www.coinbase.com/advanced-fees INTRO_1_TAKER_MAKER_FEES = (0.012, 0.006) # Intro 1: 1.2%, 0.6%: <1k monthly trading volume Coinbase taker fees tier INTRO_2_TAKER_MAKER_FEES = (0.0075, 0.0035) # Intro 2: 0.75%, 0.35%: >1k & <10k monthly trading volume Coinbase taker fees tier # simulate live fees considering the INTRO_1_TAKER_MAKER_FEES as the base tier fees to avoid # fees issues for intro 1 tier users DEFAULT_LIVE_TAKER_FEE_VALUE = INTRO_1_TAKER_MAKER_FEES[0] DEFAULT_LIVE_MAKER_FEE_VALUE = INTRO_1_TAKER_MAKER_FEES[1] # compute backtesting fees considering the INTRO_2_TAKER_MAKER_FEES as the base tier fees DEFAULT_BACKTESTING_TAKER_FEE_VALUE = INTRO_2_TAKER_MAKER_FEES[0] DEFAULT_BACKTESTING_MAKER_FEE_VALUE = INTRO_2_TAKER_MAKER_FEES[1] # disabled by default FORCE_COINBASE_BASE_FEES = os_util.parse_boolean_environment_var("FORCE_COINBASE_BASE_FEES", "false") _MAX_CURSOR_ITERATIONS = 10 def _refresh_alias_symbols(client): if client.markets: ALIASED_SYMBOLS.update({ symbol for symbol, market_status in client.markets.items() if market_status["info"].get("alias_to") }) def _coinbase_retrier(f): async def coinbase_retrier_wrapper(*args, **kwargs): last_error = None for i in range(0, Coinbase.FAKE_RATE_LIMIT_ERROR_INSTANT_RETRY_COUNT): try: return await f(*args, **kwargs) except ( octobot_trading.errors.FailedRequest, octobot_trading.errors.RateLimitExceeded, ccxt.BaseError ) as err: last_error = err if Coinbase.INSTANT_RETRY_ERROR_CODE in str(err): # should retry instantly, error on coinbase side logging.get_logger(Coinbase.get_name()).debug( f"{Coinbase.INSTANT_RETRY_ERROR_CODE} error on {f.__name__}(args={args[1:]} kwargs={kwargs}) " f"request, retrying now. Attempt {i+1} / {Coinbase.FAKE_RATE_LIMIT_ERROR_INSTANT_RETRY_COUNT}, " f"error: {err} ({last_error.__class__.__name__})." ) else: raise last_error = last_error or RuntimeError("Unknown Coinbase error") # to be able to "raise from" in next line raise octobot_trading.errors.FailedRequest( f"Failed Coinbase request after {Coinbase.FAKE_RATE_LIMIT_ERROR_INSTANT_RETRY_COUNT} " f"retries on {f.__name__}(args={args[1:]} kwargs={kwargs}) due " f"to {Coinbase.INSTANT_RETRY_ERROR_CODE} error code. " f"Last error: {last_error} ({last_error.__class__.__name__})" ) from last_error return coinbase_retrier_wrapper class CoinbaseConnector(ccxt_connector.CCXTConnector): def _client_factory( self, force_unauth, keys_adapter: typing.Callable[[exchanges.ExchangeCredentialsData], exchanges.ExchangeCredentialsData]=None ) -> tuple: return super()._client_factory(force_unauth, keys_adapter=self._keys_adapter) def _keys_adapter(self, creds: exchanges.ExchangeCredentialsData) -> exchanges.ExchangeCredentialsData: if creds.auth_token: # when auth token is provided, force invalid keys creds.api_key = "ANY_KEY" creds.secret = "ANY_KEY" creds.auth_token_header_prefix = "Bearer " # CCXT pem key reader is not expecting users to under keys pasted as text from the coinbase UI # convert \\n to \n to make this format compatible as well if creds.secret and "\\n" in creds.secret: creds.secret = creds.secret.replace("\\n", "\n") return creds @_coinbase_retrier async def _load_markets( self, client, reload: bool, market_filter: typing.Optional[typing.Callable[[dict], bool]] = None ): # override for retrier and populate ALIASED_SYMBOLS await self._filtered_if_necessary_load_markets(client, reload, market_filter) # only call _refresh_alias_symbols from here as markets just got reloaded, # no market can be missing unlike when using cached markets _refresh_alias_symbols(client) if FORCE_COINBASE_BASE_FEES: # always use base fee tiers inside OctoBot to avoid issues with coinbase high fees self._apply_base_fee_tiers() @classmethod def register_simulator_connector_fee_methods( cls, exchange_name: str, simulator_connector: exchanges.ExchangeSimulatorConnector ): if FORCE_COINBASE_BASE_FEES: # only called in backtesting # overrides exchange simulator connector get_fees to use backtesting fees simulator_connector.get_fees = cls.simulator_connector_get_fees @classmethod def simulator_connector_get_fees(cls, symbol: str): # same signature as ExchangeSimulatorConnector.get_fees # force selecetd fee tier in backtesting return { trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value: DEFAULT_BACKTESTING_TAKER_FEE_VALUE, trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value: DEFAULT_BACKTESTING_MAKER_FEE_VALUE, trading_enums.ExchangeConstantsMarketPropertyColumns.FEE.value: trading_constants.CONFIG_DEFAULT_SIMULATOR_FEES } def _apply_base_fee_tiers(self): taker_fee, maker_fee = self._get_base_tier_fees() self.logger.info( f"Applying {self.exchange_manager.exchange_name} base fees tiers to markets: {taker_fee=}, {maker_fee=}" ) for market in self.client.markets.values(): market[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value] = taker_fee market[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value] = maker_fee def _get_base_tier_fees(self) -> (float, float): return ( DEFAULT_LIVE_TAKER_FEE_VALUE, DEFAULT_LIVE_MAKER_FEE_VALUE ) # TODO uncomment this in case there is a way to fetch tier 0 fees in Coinbase # try: # # use ccxt default fee tiers # fee_tiers = self.client.describe()["fees"]["trading"]["tiers"] # return ( # fee_tiers[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value][0][1], # fee_tiers[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value][0][1], # ) # except KeyError as err: # self.logger.error( # f"Error when getting base fee tier: {err}. Using default {DEFAULT_FEE_VALUE} value" # ) # return ( # DEFAULT_TAKER_FEE_VALUE, DEFAULT_MAKER_FEE_VALUE # ) async def _edit_order_by_cancel_and_create( self, exchange_order_id: str, symbol: str, order_type: trading_enums.TraderOrderType, side: str, quantity: float, price: float, params: dict ) -> dict: if order_type == trading_enums.TraderOrderType.STOP_LOSS: # can't use super()._edit_order_by_cancel_and_create when order is a stop loss as stop market orders # are not supported await self.client.cancel_order(exchange_order_id, symbol) stop_price = price price = float( decimal.Decimal(str(price)) * self.exchange_manager.exchange.STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO ) local_param = copy.deepcopy(params) return await self.create_limit_stop_loss_order(symbol, quantity, price, stop_price, side, params=local_param) # not a stop loss: proceed with the usual edit flow return await super()._edit_order_by_cancel_and_create( exchange_order_id, symbol, order_type, side, quantity, price, params ) @ccxt_client_util.converted_ccxt_common_errors async def get_balance(self, **kwargs: dict): """ Local override to handle pagination of coinbase's max of 250 assets per request fetch balance (free + used) by currency :return: balance dict """ if not kwargs: kwargs = {} with self.error_describer(): results = await self._paginated_request(self.client.fetch_balance, params=kwargs) merged_balances = {} for result in results: merged_balances.update(result) return self.adapter.adapt_balance(merged_balances) @_coinbase_retrier async def _paginated_request(self, func, *args, **kwargs): results = [await func(*args, **kwargs)] if "params" not in kwargs: kwargs["params"] = {} next_cursor = "" i = 0 for i in range(_MAX_CURSOR_ITERATIONS): if next_cursor := self._get_next_cursor(results[-1], func.__name__): self.logger.info(f"Large portfolio fetch in progress: request [{i}] processing ...") kwargs["params"]["cursor"] = next_cursor results.append(await func(*args, **kwargs)) else: break if next_cursor: self.logger.error( f"Not all {self.exchange_manager.exchange_name} {func.__name__} was fetched after [{i + 1}] " f"iterations. This is unexpected." ) return results def _get_next_cursor(self, response: dict, func_name: str) -> str: try: return response[ccxt_constants.CCXT_INFO]["cursor"] except KeyError: self.logger.error( f"Unexpected missing cursor key in {self.exchange_manager.exchange_name} {func_name} response info, " f"available keys: {list(response[ccxt_constants.CCXT_INFO])}" ) return "" @ccxt_client_util.converted_ccxt_common_errors async def _ensure_auth(self): # Override of ccxt_connector._ensure_auth to use get_open_orders instead and propagate authentication errors try: # load markets before calling _ensure_auth() to avoid fetching markets status while they are cached await self._unauth_ensure_exchange_init() # replace self.exchange_manager.exchange.get_balance by get_open_orders # to mitigate coinbase balance cache side effect if self.client.markets: # fetch orders for any available symbol to ensure authentication is working first_symbol = next(iter(self.client.markets.keys())) await self.exchange_manager.exchange.get_open_orders(symbol=first_symbol) else: self.logger.error( f"Unexpected: No [{self.exchange_manager.exchange_name}] markets loaded. Impossible to check authentication." ) except ( octobot_trading.errors.AuthenticationError, octobot_trading.errors.ExchangeProxyError, ccxt.AuthenticationError ): # this error is critical on coinbase as it prevents loading markets: propagate it raise except Exception as err: if self.force_authentication: raise # Is probably handled in exchange tentacles, important thing here is that authentication worked self.logger.warning( f"Error when checking exchange connection: {err} ({err.__class__.__name__}). This should not be an issue." ) class Coinbase(exchanges.RestExchange): MAX_PAGINATION_LIMIT: int = 300 ALWAYS_REQUIRES_AUTHENTICATION = True IS_SKIPPING_EMPTY_CANDLES_IN_OHLCV_FETCH = True DEFAULT_CONNECTOR_CLASS = CoinbaseConnector FAKE_RATE_LIMIT_ERROR_INSTANT_RETRY_COUNT = 5 INSTANT_RETRY_ERROR_CODE = "429" FIX_MARKET_STATUS = True # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True # text content of errors due to orders not found errors EXCHANGE_ORDER_NOT_FOUND_ERRORS: typing.List[typing.Iterable[str]] = [ # coinbase {"error":"NOT_FOUND","error_details":"order with this orderID was not found", # "message":"order with this orderID was not found"} ("not_found", "order") ] # text content of errors due to api key permissions issues EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # coinbase ex: coinbase {"error":"PERMISSION_DENIED", # "error_details":"Missing required scopes","message":"Missing required scopes"} # ExchangeError('coinbase {"error":"unknown","error_details":"Missing required scopes", # "message":"Missing required scopes"}') ("missing required scopes", ), ("permission is required", ), ] # text content of errors due to traded assets for account EXCHANGE_ACCOUNT_TRADED_SYMBOL_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # ex when trading WBTC/USDC with and account that can't trade it: # ccxt.base.errors.BadRequest: target is not enabled for trading ("target is not enabled for trading", ), # ccxt.base.errors.PermissionDenied: coinbase {"error":"PERMISSION_DENIED","error_details": # "User is not allowed to convert crypto","message":"User is not allowed to convert crypto"} ("user is not allowed to convert crypto", ), ] # text content of errors due to exchange internal synch (like when portfolio is not yet up to date after a trade) EXCHANGE_INTERNAL_SYNC_ERRORS: typing.List[typing.Iterable[str]] = [ # BadRequest coinbase {"error":"INVALID_ARGUMENT","error_details":"account is not available","message":"account is not available"} ("account is not available", ) ] # text content of errors due to missing fnuds when creating an order (when not identified as such by ccxt) EXCHANGE_MISSING_FUNDS_ERRORS: typing.List[typing.Iterable[str]] = [ ("insufficient balance in source account", ) ] # text content of errors due to an order that can't be cancelled on exchange (because filled or already cancelled) EXCHANGE_ORDER_UNCANCELLABLE_ERRORS: typing.List[typing.Iterable[str]] = [ ('cancelorders() has failed, check your arguments and parameters', ) ] # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request # not supported or need custom mechanics with batch orders trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot (as spot limit) trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server # stop limit price is 2% bellow trigger price to ensure instant fill STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO = decimal.Decimal("0.98") @classmethod def get_name(cls): return 'coinbase' def get_adapter_class(self): return CoinbaseCCXTAdapter @staticmethod def get_default_reference_market(exchange_name: str) -> str: return "USDC" def get_alias_symbols(self) -> set[str]: """ :return: a set of symbol of this exchange that are aliases to other symbols """ return ALIASED_SYMBOLS def supports_native_edit_order(self, order_type: trading_enums.TraderOrderType) -> bool: # return False when default edit_order can't be used and order should always be canceled and recreated instead # only working with regular limit orders return order_type not in ( trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT ) async def get_account_id(self, **kwargs: dict) -> str: try: with self.connector.error_describer(): accounts = await self.connector.client.fetch_accounts() # use portfolio id when possible to enable "coinbase subaccounts" which are called "portfolios" # note: oldest portfolio portfolio id == user id (from previous v2PrivateGetUser) when # using master account portfolio_ids = set(account[ccxt_constants.CCXT_INFO]['retail_portfolio_id'] for account in accounts) if len(portfolio_ids) != 1: is_up_to_date_key = self._is_up_to_date_api_key() if is_up_to_date_key: self.logger.error( f"Unexpected: failed to identify Coinbase portfolio id on up to date API keys: " f"{portfolio_ids=}" ) sorted_portfolios = sorted( [ account[ccxt_constants.CCXT_INFO] for account in accounts ], key=lambda account: account["created_at"], ) portfolio_id = sorted_portfolios[0]['retail_portfolio_id'] self.logger.info( f"{len(portfolio_ids)} portfolio found on Coinbase account. " f"This can happen with non up-to-date API keys ({is_up_to_date_key=}). " f"Using the oldest portfolio id to bind to main account: {portfolio_id=}." ) else: portfolio_id = next(iter(portfolio_ids)) return portfolio_id except ccxt.AuthenticationError: raise except (ccxt.BaseError, octobot_trading.errors.OctoBotExchangeError) as err: self.logger.exception( err, True, f"Error when fetching {self.get_name()} account id: {err} ({err.__class__.__name__}). " f"This is not normal, endpoint might be deprecated, see " f"https://docs.cloud.coinbase.com/sign-in-with-coinbase/docs/api-users. " f"Using generated account id instead" ) return trading_constants.DEFAULT_ACCOUNT_ID def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: # unknown (05/06/2025) return super().get_max_orders_count(symbol, order_type) def _is_up_to_date_api_key(self) -> bool: return ( self.connector.client.apiKey.find('organizations/') >= 0 or self.connector.client.apiKey.startswith('-----BEGIN') ) @_coinbase_retrier async def get_symbol_prices(self, symbol: str, time_frame: commons_enums.TimeFrames, limit: int = None, **kwargs: dict) -> typing.Optional[list]: return await super().get_symbol_prices( symbol, time_frame, **self._get_ohlcv_params(time_frame, limit, **kwargs) ) @_coinbase_retrier async def get_recent_trades(self, symbol, limit=50, **kwargs): # override for retrier return await super().get_recent_trades(symbol, limit=limit, **kwargs) @_coinbase_retrier async def get_price_ticker(self, symbol: str, **kwargs: dict) -> typing.Optional[dict]: # override for retrier return await super().get_price_ticker(symbol, **kwargs) @_coinbase_retrier async def get_all_currencies_price_ticker(self, **kwargs: dict) -> typing.Optional[dict[str, dict]]: # override for retrier return await super().get_all_currencies_price_ticker(**kwargs) @_coinbase_retrier async def cancel_order( self, exchange_order_id: str, symbol: str, order_type: trading_enums.TraderOrderType, **kwargs: dict ) -> trading_enums.OrderStatus: # override for retrier return await super().cancel_order(exchange_order_id, symbol, order_type, **kwargs) async def get_balance(self, **kwargs: dict): # warning: sometimes has unexpected delays after creating / filling orders if "v3" not in kwargs: # use v3 to get free and total amounts (default is only returning free amounts) kwargs["v3"] = True return await super().get_balance(**kwargs) @_coinbase_retrier async def _create_order_with_retry(self, order_type, symbol, quantity: decimal.Decimal, price: decimal.Decimal, stop_price: decimal.Decimal, side: trading_enums.TradeOrderSide, current_price: decimal.Decimal, reduce_only: bool, params) -> dict: # override for retrier return await super()._create_order_with_retry( order_type=order_type, symbol=symbol, quantity=quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params ) @_coinbase_retrier async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: # override for retrier return await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs) @_coinbase_retrier async def get_order( self, exchange_order_id: str, symbol: typing.Optional[str] = None, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs: dict ) -> dict: # override for retrier return await super().get_order(exchange_order_id, symbol=symbol, order_type=order_type, **kwargs) async def _create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: # warning coinbase only supports stop limit orders, stop markets are not available stop_price = price price = float(decimal.Decimal(str(price)) * self.STOP_LIMIT_ORDER_INSTANT_FILL_PRICE_RATIO) # use limit stop loss with a "normally instantly" filled price return await self._create_limit_stop_loss_order(symbol, quantity, price, stop_price, side, params=params) def _get_ohlcv_params(self, time_frame, input_limit, **kwargs): limit = input_limit if not input_limit or input_limit > self.MAX_PAGINATION_LIMIT: limit = min(self.MAX_PAGINATION_LIMIT, input_limit) if input_limit else self.MAX_PAGINATION_LIMIT if "since" not in kwargs: time_frame_sec = commons_enums.TimeFramesMinutes[time_frame] * commons_constants.MSECONDS_TO_MINUTE to_time = self.connector.client.milliseconds() kwargs["since"] = to_time - (time_frame_sec * limit) kwargs["limit"] = limit return kwargs def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: signature_identifier = "CB-ACCESS-SIGN" oauth_identifier = "Authorization" return bool( headers and ( signature_identifier in headers or oauth_identifier in headers ) ) def is_market_open_for_order_type(self, symbol: str, order_type: trading_enums.TraderOrderType) -> bool: """ Override if necessary """ market_status_info = self.get_market_status(symbol, with_fixer=False).get(ccxt_constants.CCXT_INFO, {}) trade_order_type = order_util.get_trade_order_type(order_type) try: if trade_order_type is trading_enums.TradeOrderType.MARKET: return not market_status_info["limit_only"] if trade_order_type is trading_enums.TradeOrderType.LIMIT: return not market_status_info["cancel_only"] except KeyError as err: self.logger.exception( err, True, f"Can't check {self.get_name()} market opens status for order type: missing {err} " f"in market status info. {self.get_name()} API probably changed. Considering market as open. " f"market_status_info: {market_status_info}" ) return True class CoinbaseCCXTAdapter(exchanges.CCXTAdapter): def _register_exchange_fees(self, order_or_trade): super()._register_exchange_fees(order_or_trade) try: fees = order_or_trade[trading_enums.ExchangeConstantsOrderColumns.FEE.value] if not fees[trading_enums.FeePropertyColumns.CURRENCY.value]: # fees currency are not provided, they are always in quote on Coinbase fees[trading_enums.FeePropertyColumns.CURRENCY.value] = commons_symbols.parse_symbol( order_or_trade[trading_enums.ExchangeConstantsOrderColumns.SYMBOL.value] ).quote except (KeyError, TypeError): pass def _update_stop_order_or_trade_type_and_price(self, order_or_trade: dict): if stop_price := order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.STOP_PRICE.value): # from https://bingx-api.github.io/docs/#/en-us/spot/trade-api.html#Current%20Open%20Orders limit_price = order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.PRICE.value) # use stop price as order price to parse it properly order_or_trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = stop_price # type is TAKE_STOP_LIMIT (not unified) if order_or_trade.get(trading_enums.ExchangeConstantsOrderColumns.TYPE.value) not in ( trading_enums.TradeOrderType.STOP_LOSS.value, trading_enums.TradeOrderType.TAKE_PROFIT.value ): # Force stop loss. Add order direction parsing logic to handle take profits if necessary order_type = trading_enums.TradeOrderType.STOP_LOSS.value trigger_above = False try: order_config = order_or_trade.get(ccxt_constants.CCXT_INFO, {}).get("order_configuration", {}) stop_config = order_config.get("stop_limit_stop_limit_gtc") or order_config.get("stop_limit_stop_limit_gtd") stop_direction = stop_config.get("stop_direction", "") if "down" in stop_direction.lower(): trigger_above = False elif "up" in stop_direction.lower(): trigger_above = True else: self.logger.error(f"Unknown order direction: {stop_direction} ({order_or_trade})") side = order_or_trade[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] if side == trading_enums.TradeOrderSide.SELL.value: if trigger_above: # take profits are not yet handled as such: consider them as limit orders order_type = trading_enums.TradeOrderType.LIMIT.value # waiting for TP handling else: order_type = trading_enums.TradeOrderType.STOP_LOSS.value elif side == trading_enums.TradeOrderSide.BUY.value: if trigger_above: order_type = trading_enums.TradeOrderType.STOP_LOSS.value else: # take profits are not yet handled as such: consider them as limit orders order_type = trading_enums.TradeOrderType.LIMIT.value # waiting for TP handling except (KeyError, TypeError) as err: self.logger.error(f"missing expected coinbase order config: {err}, {order_or_trade}") order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = order_type order_or_trade[trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value] = trigger_above def fix_order(self, raw, **kwargs): """ Handle 'order_type': 'UNKNOWN_ORDER_TYPE in coinbase order response (translated into None in ccxt order type) ex: {'info': {'order_id': 'd7471b4e-960e-4c92-bdbf-755cb92e176b', 'product_id': 'AAVE-USD', 'user_id': '9868efd7-90e1-557c-ac0e-f6b943d471ad', 'order_configuration': {'limit_limit_gtc': {'base_size': '6.798', 'limit_price': '110.92', 'post_only': False}}, 'side': 'BUY', 'client_order_id': '465ead64-6272-4e92-97e2-59653de3ca24', 'status': 'OPEN', 'time_in_force': 'GOOD_UNTIL_CANCELLED', 'created_time': '2024-03-02T03:04:11.070126Z', 'completion_percentage': '0', 'filled_size': '0', 'average_filled_price': '0', 'fee': '', 'number_of_fills': '0', 'filled_value': '0', 'pending_cancel': False, 'size_in_quote': False, 'total_fees': '0', 'size_inclusive_of_fees': False, 'total_value_after_fees': '757.05029664', 'trigger_status': 'INVALID_ORDER_TYPE', 'order_type': 'UNKNOWN_ORDER_TYPE', 'reject_reason': 'REJECT_REASON_UNSPECIFIED', 'settled': False, 'product_type': 'SPOT', 'reject_message': '', 'cancel_message': '', 'order_placement_source': 'RETAIL_ADVANCED', 'outstanding_hold_amount': '757.05029664', 'is_liquidation': False, 'last_fill_time': None, 'edit_history': [], 'leverage': '', 'margin_type': 'UNKNOWN_MARGIN_TYPE'}, 'clientOrderId': '465ead64-6272-4e92-97e2-59653de3ca24', 'timestamp': 1709348651.07, 'datetime': '2024-03-02T03:04:11.070126Z', 'lastTradeTimestamp': None, 'symbol': 'AAVE/USD', 'type': None, 'timeInForce': 'GTC', 'postOnly': False, 'side': 'buy', 'price': 110.92, 'stopPrice': None, 'triggerPrice': None, 'amount': 6.798, 'filled': 0.0, 'remaining': 6.798, 'cost': 0.0, 'average': None, 'status': 'open', 'fee': {'cost': '0', 'currency': 'USD', 'exchange_original_cost': '0', 'is_from_exchange': True}, 'trades': [], 'fees': [{'cost': 0.0, 'currency': 'USD'}], 'lastUpdateTimestamp': None, 'reduceOnly': None, 'takeProfitPrice': None, 'stopLossPrice': None, 'exchange_id': 'd7471b4e-960e-4c92-bdbf-755cb92e176b'} """ fixed = super().fix_order(raw, **kwargs) self._update_stop_order_or_trade_type_and_price(fixed) if fixed[ccxt_enums.ExchangeOrderCCXTColumns.TYPE.value] is None: if fixed[ccxt_enums.ExchangeOrderCCXTColumns.STOP_PRICE.value] is not None: # stop price set: stop order order_type = trading_enums.TradeOrderType.STOP_LOSS.value elif fixed[ccxt_enums.ExchangeOrderCCXTColumns.PRICE.value] is None: # price not set: market order order_type = trading_enums.TradeOrderType.MARKET.value else: # price is set and stop price is not: limit order order_type = trading_enums.TradeOrderType.LIMIT.value fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = order_type if fixed[ccxt_enums.ExchangeOrderCCXTColumns.STATUS.value] == "PENDING": fixed[ccxt_enums.ExchangeOrderCCXTColumns.STATUS.value] = trading_enums.OrderStatus.PENDING_CREATION.value if fixed[ccxt_enums.ExchangeOrderCCXTColumns.STATUS.value] == "CANCEL_QUEUED": fixed[ccxt_enums.ExchangeOrderCCXTColumns.STATUS.value] = trading_enums.OrderStatus.PENDING_CANCEL.value # sometimes amount is not set if not fixed[ccxt_enums.ExchangeOrderCCXTColumns.AMOUNT.value] \ and fixed[ccxt_enums.ExchangeOrderCCXTColumns.FILLED.value]: fixed[ccxt_enums.ExchangeOrderCCXTColumns.AMOUNT.value] = \ fixed[ccxt_enums.ExchangeOrderCCXTColumns.FILLED.value] return fixed def fix_trades(self, raw, **kwargs): raw = super().fix_trades(raw, **kwargs) for trade in raw: self._update_stop_order_or_trade_type_and_price(trade) trade[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CLOSED.value try: if trade[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] is None and \ trade[trading_enums.ExchangeConstantsOrderColumns.COST.value] and \ trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]: # convert amount to have the same units as every other exchange trade[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] = ( trade[trading_enums.ExchangeConstantsOrderColumns.COST.value] / trade[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] ) except KeyError: pass return raw ================================================ FILE: Trading/Exchange/coinbase/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Coinbase"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/coinbase_pro/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .coinbase_pro_exchange import CoinbasePro ================================================ FILE: Trading/Exchange/coinbase_pro/coinbase_pro_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges class CoinbasePro(exchanges.RestExchange): MAX_PAGINATION_LIMIT: int = 100 # value from https://docs.pro.coinbase.com/#pagination FIX_MARKET_STATUS = True @classmethod def get_name(cls): return 'coinbasepro' def get_adapter_class(self): return CoinbaseProCCXTAdapter async def get_my_recent_trades(self, symbol=None, since=None, limit=None, **kwargs): return self._uniformize_trades(await super().get_my_recent_trades(symbol=symbol, since=since, limit=self._fix_limit(limit), **kwargs)) async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await super().get_open_orders(symbol=symbol, since=since, limit=self._fix_limit(limit), **kwargs) async def get_closed_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await super().get_closed_orders(symbol=symbol, since=since, limit=self._fix_limit(limit), **kwargs) def _fix_limit(self, limit: int) -> int: return min(self.MAX_PAGINATION_LIMIT, limit) if limit else limit def _uniformize_trades(self, trades): if not trades: return [] for trade in trades: trade[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CLOSED.value trade[trading_enums.ExchangeConstantsOrderColumns.EXCHANGE_ID.value] = trade[ trading_enums.ExchangeConstantsOrderColumns.ORDER.value ] trade[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = trading_enums.TradeOrderType.MARKET.value \ if trade["takerOrMaker"] == trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value \ else trading_enums.TradeOrderType.LIMIT.value return trades class CoinbaseProCCXTAdapter(exchanges.CCXTAdapter): def fix_trades(self, raw, **kwargs): raw = super().fix_trades(raw, **kwargs) for trade in raw: trade[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = trading_enums.OrderStatus.CLOSED.value return raw ================================================ FILE: Trading/Exchange/coinbase_pro/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CoinbasePro"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/coinbase_pro_websocket_feed/__init__.py ================================================ from .coinbase_pro_websocket import CoinbaseProCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/coinbase_pro_websocket_feed/coinbase_pro_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.coinbase_pro.coinbase_pro_exchange as coinbase_pro_exchange class CoinbaseProCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: Feeds.UNSUPPORTED.value, Feeds.TICKER: True, Feeds.CANDLE: Feeds.UNSUPPORTED.value, } @classmethod def get_name(cls): return coinbase_pro_exchange.CoinbasePro.get_name() def get_adapter_class(self, adapter_class): return coinbase_pro_exchange.CoinbaseProCCXTAdapter ================================================ FILE: Trading/Exchange/coinbase_pro_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CoinbaseProCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/coinbase_pro_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/coinbase_pro_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...coinbase_pro_websocket_feed import CoinbaseProCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() async with websocket_test_tools.ws_exchange_manager(config, CoinbaseProCryptofeedWebsocketConnector.get_name()) \ as exchange_manager_instance: await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=CoinbaseProCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USD", "ETH/USD"], time_frames=[commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value }, time_before_assert=20 ) ================================================ FILE: Trading/Exchange/coinbase_websocket_feed/__init__.py ================================================ from .coinbase_websocket import CoinbaseCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/coinbase_websocket_feed/coinbase_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.coinbase.coinbase_exchange as coinbase_exchange class CoinbaseCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: Feeds.UNSUPPORTED.value, Feeds.TICKER: True, Feeds.CANDLE: Feeds.UNSUPPORTED.value, } @classmethod def get_name(cls): return coinbase_exchange.Coinbase.get_name() def _get_keys_adapter(self): return self.exchange_manager.exchange.connector._keys_adapter def get_adapter_class(self, adapter_class): return coinbase_exchange.CoinbaseCCXTAdapter ================================================ FILE: Trading/Exchange/coinbase_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CoinbaseCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/coinex/__init__.py ================================================ from .coinex_exchange import Coinex ================================================ FILE: Trading/Exchange/coinex/coinex_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.enums as trading_enums import octobot_trading.constants as constants class Coinex(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True MAX_PAGINATION_LIMIT: int = 100 # text content of errors due to orders not found errors EXCHANGE_ORDER_NOT_FOUND_ERRORS: typing.List[typing.Iterable[str]] = [ # ExchangeError('coinex Order not found') ("order not found", ) ] SUPPORT_FETCHING_CANCELLED_ORDERS = False # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True @classmethod def get_name(cls): return 'coinex' def get_adapter_class(self): return CoinexCCXTAdapter def get_additional_connector_config(self): # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders return { ccxt_constants.CCXT_OPTIONS: { "createMarketBuyOrderRequiresPrice": False # disable quote conversion } } async def get_account_id(self, **kwargs: dict) -> str: # current impossible to get account UID (22/02/25) return constants.DEFAULT_ACCOUNT_ID def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: v1_signature_identifiers = "Authorization" v2_signature_identifiers = "X-COINEX-SIGN" return bool( headers and ( v1_signature_identifiers in headers or v2_signature_identifiers in headers ) ) async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await super().get_open_orders(symbol=symbol, since=since, limit=self._fix_limit(limit), **kwargs) async def get_closed_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await super().get_closed_orders(symbol=symbol, since=since, limit=self._fix_limit(limit), **kwargs) def _fix_limit(self, limit: int) -> int: return min(self.MAX_PAGINATION_LIMIT, limit) if limit else limit class CoinexCCXTAdapter(exchanges.CCXTAdapter): def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) self.adapt_amount_from_filled_or_cost(fixed) try: if fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] is None: fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = \ trading_enums.OrderStatus.CLOSED.value # from https://docs.coinex.com/api/v2/enum#order_status elif fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == "part_filled": # order partially executed (still pending) fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = \ trading_enums.OrderStatus.OPEN.value elif fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == "part_canceled": # order partially executed and then canceled fixed[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] = \ trading_enums.OrderStatus.CANCELED.value except KeyError: pass return fixed def fix_trades(self, raw, **kwargs): raw = super().fix_trades(raw, **kwargs) for trade in raw: info = trade[ccxt_constants.CCXT_INFO] # fees are not parsed by ccxt fee = trade[trading_enums.ExchangeConstantsOrderColumns.FEE.value] or {} if not fee.get(trading_enums.FeePropertyColumns.CURRENCY.value): fee[trading_enums.FeePropertyColumns.CURRENCY.value] = info.get("fee_ccy") if not fee.get(trading_enums.FeePropertyColumns.COST.value): fee[trading_enums.FeePropertyColumns.COST.value] = info.get("fee") trade[trading_enums.ExchangeConstantsOrderColumns.FEE.value] = fee self._register_exchange_fees(trade) return raw def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Exchange/coinex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Coinex"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/coinex/resources/coinex.md ================================================ coinex is a basic RestExchange adaptation for coinex exchange. ================================================ FILE: Trading/Exchange/coinex_websocket_feed/__init__.py ================================================ from .coinex_websocket import CoinexCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/coinex_websocket_feed/coinex_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.coinex.coinex_exchange as coinex_exchange class CoinexCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: Feeds.UNSUPPORTED.value, # only for swap markets (futures) Feeds.TICKER: True, Feeds.CANDLE: Feeds.UNSUPPORTED.value, # only for swap markets (futures) } @classmethod def get_name(cls): return coinex_exchange.Coinex.get_name() ================================================ FILE: Trading/Exchange/coinex_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CoinexCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/configurable_default_ccxt_rest/__init__.py ================================================ # Drakkar-Software OctoBot-Private-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .configurable_default_rest_ccxt_exchange import * ================================================ FILE: Trading/Exchange/configurable_default_ccxt_rest/configurable_default_rest_ccxt_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class ConfigurableDefaultCCXTRestExchange(exchanges.DefaultRestExchange): @classmethod def load_user_inputs_from_class(cls, tentacles_setup_config, tentacle_config): # bypass parent to use the real load_user_inputs and enable user inputs configuration return exchanges.RestExchange.load_user_inputs_from_class(tentacles_setup_config, tentacle_config) ================================================ FILE: Trading/Exchange/configurable_default_ccxt_rest/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ConfigurableDefaultCCXTRestExchange"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/configurable_default_ccxt_rest/resources/configurable_default_rest_ccxt_exchange.md ================================================ ConfigurableDefaultCCXTRestExchange is a basic RestExchange that supports user input configuration. ================================================ FILE: Trading/Exchange/cryptocom/__init__.py ================================================ # Drakkar-Software OctoBot-Private-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .cryptocom_exchange import * ================================================ FILE: Trading/Exchange/cryptocom/cryptocom_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class CryptoCom(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True EXPECT_POSSIBLE_ORDER_NOT_FOUND_DURING_ORDER_CREATION = True # set True when get_order() can return None # (order not found) when orders are instantly filled on exchange and are not fully processed on the exchange side. SUPPORT_FETCHING_CANCELLED_ORDERS = False @classmethod def get_name(cls): return 'cryptocom' ================================================ FILE: Trading/Exchange/cryptocom/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CryptoCom"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/cryptocom_websocket_feed/__init__.py ================================================ from .cryptocom_websocket import CryptoComCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/cryptocom_websocket_feed/cryptocom_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.cryptocom.cryptocom_exchange as cryptocom_exchange class CryptoComCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return cryptocom_exchange.CryptoCom.get_name() ================================================ FILE: Trading/Exchange/cryptocom_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["CryptoComCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/gateio/__init__.py ================================================ from .gateio_exchange import GateIO ================================================ FILE: Trading/Exchange/gateio/gateio_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums import typing class GateIO(exchanges.RestExchange): ORDERS_LIMIT = 100 FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True # text content of errors due to unhandled IP white list issues EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # gateio {"message":"Request IP not in whitelist: 11.11.11.11","label":"FORBIDDEN"} ("ip not in whitelist",), ] ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server @classmethod def get_name(cls): return 'gateio' def get_adapter_class(self): return GateioCCXTAdapter async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await super().get_open_orders(symbol=symbol, since=since, limit=min(self.ORDERS_LIMIT, limit) if limit is not None else None, **kwargs) class GateioCCXTAdapter(exchanges.CCXTAdapter): def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Exchange/gateio/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GateIO"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/gateio/resources/gateio.md ================================================ GateIO is a basic RestExchange adaptation for GateIO exchange. ================================================ FILE: Trading/Exchange/gateio_websocket_feed/__init__.py ================================================ from .gateio_websocket import GateIOCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/gateio_websocket_feed/gateio_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.gateio.gateio_exchange as gateio_exchange class GateIOCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return gateio_exchange.GateIO.get_name() def get_adapter_class(self, adapter_class): return gateio_exchange.GateioCCXTAdapter ================================================ FILE: Trading/Exchange/gateio_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GateIOCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/gateio_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/gateio_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.exchanges_test_tools as exchanges_test_tools import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...gateio_websocket_feed import GateIOCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() exchange_manager_instance = await exchanges_test_tools.create_test_exchange_manager( config=config, exchange_name=GateIOCryptofeedWebsocketConnector.get_name()) await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=GateIOCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_MINUTE, commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value, }, time_before_assert=20 ) await exchanges_test_tools.stop_test_exchange_manager(exchange_manager_instance) ================================================ FILE: Trading/Exchange/hitbtc/__init__.py ================================================ from .hitbtc_exchange import Hitbtc ================================================ FILE: Trading/Exchange/hitbtc/hitbtc_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class Hitbtc(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True @classmethod def get_name(cls): return 'hitbtc' async def get_symbol_prices(self, symbol, time_frame, limit: int = None, **kwargs: dict): return await super().get_symbol_prices(symbol, time_frame, limit=limit, sort='DESC', **kwargs) ================================================ FILE: Trading/Exchange/hitbtc/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Hitbtc"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/hitbtc/resources/hitbtc.md ================================================ Hitbtc is a basic RestExchange adaptation for Hitbtc exchange. ================================================ FILE: Trading/Exchange/hitbtc/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/hollaex/__init__.py ================================================ from .hollaex_exchange import hollaex ================================================ FILE: Trading/Exchange/hollaex/config/hollaex.json ================================================ { "rest": "https://api.hollaex.com" } ================================================ FILE: Trading/Exchange/hollaex/hollaex_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ccxt import typing import decimal import enum import cachetools import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.symbols as symbols_utils import octobot_commons.logging as logging import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.exchanges as exchanges import octobot_trading.errors as errors import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums import octobot_trading.exchanges.connectors.ccxt.ccxt_clients_cache as ccxt_clients_cache _EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME: dict[str, dict] = {} # refresh exchange fee tiers every day but don't delete outdated info, only replace it with updated ones _REFRESHED_EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME : cachetools.TTLCache[str, bool] = cachetools.TTLCache( maxsize=50, ttl=commons_constants.DAYS_TO_SECONDS ) DEFAULT_FEE_SIDE = trading_enums.ExchangeFeeSides.GET.value # the fee is always in the currency you get class FeeTiers(enum.Enum): BASIC = "1" VIP = "2" class hollaexConnector(exchanges.CCXTConnector): async def load_symbol_markets( self, reload=False, market_filter: typing.Union[None, typing.Callable[[dict], bool]] = None ): await super().load_symbol_markets(reload=reload, market_filter=market_filter) await self.disable_quick_trade_only_pairs() # also refresh fee tiers when necessary if self.exchange_manager.exchange_name not in _REFRESHED_EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME: authenticated_cache = self.exchange_manager.exchange.requires_authentication_for_this_configuration_only() # always update fees cache using all markets to avoid market filter side effects from the current client all_markets = ccxt_clients_cache.get_exchange_parsed_markets( ccxt_clients_cache.get_client_key(self.client, authenticated_cache) ) await self._refresh_exchange_fee_tiers(all_markets) async def disable_quick_trade_only_pairs(self): # on hollaex exchanges, a market can be "quick trade only" or "spot order book trade" as well. # a quick trade only market can't be traded like a spot market, disable it. exchange_constants = await self.client.publicGetConstants() quick_trade_only_pairs = self._parse_quick_trades_only_pairs(exchange_constants) if disabled_pairs := [ pair for pair in quick_trade_only_pairs if pair in self.client.markets ]: self.logger.info( f"Disabling [{self.exchange_manager.exchange_name}] {len(disabled_pairs)} quick trade only pairs: {disabled_pairs}" ) for disabled_pair in disabled_pairs: self._disable_pair(disabled_pair) def _disable_pair(self, symbol: str): if symbol in self.client.markets: self.client.markets[symbol][trading_enums.ExchangeConstantsMarketStatusColumns.ACTIVE.value] = False def _parse_quick_trades_only_pairs(self, exchange_constants: dict) -> list[str]: if 'quicktrade' not in exchange_constants: self.logger.error( f"Unexpected [{self.exchange_manager.exchange_name}] no 'quicktrade' key found in exchange constants" ) return [] quick_trade_details = exchange_constants['quicktrade'] # format: [{'type': 'network', 'symbol': 'rune-usdt', 'active': True}, ...] quick_trade_only_pairs = [] for pair_details in quick_trade_details: if "type" not in pair_details or "symbol" not in pair_details: self.logger.error(f"Ignored invalid quick trade only pair details: {pair_details}") continue # type=pro means this pair is traded in spot order book markets, otherwise it's a quick trade only pair if pair_details['type'] != "pro": market_id = pair_details["symbol"] market = self.client.safe_market(market_id, None, '-') quick_trade_only_pairs.append( market[trading_enums.ExchangeConstantsMarketStatusColumns.SYMBOL.value] ) return quick_trade_only_pairs async def _refresh_exchange_fee_tiers(self, all_markets: list[dict]): self.logger.info(f"Refreshing {self.exchange_manager.exchange_name} fee tiers") response = await self.client.publicGetTiers() # similar to ccxt's fetch_trading_fees except that we parse all tiers if not response: self.logger.error("No fee tiers available") fees_by_tier = {} for tier, values in response.items(): fees = self.client.safe_value(values, 'fees', {}) makerFees = self.client.safe_value(fees, 'maker', {}) takerFees = self.client.safe_value(fees, 'taker', {}) result: dict = {} for market in all_markets: # get symbol, taker and maker fee for each traded pair identified by its id symbol = market[trading_enums.ExchangeConstantsMarketStatusColumns.SYMBOL.value] maker_string = self.client.safe_string( makerFees, market[trading_enums.ExchangeConstantsMarketStatusColumns.ID.value] ) taker_string = self.client.safe_string( takerFees, market[trading_enums.ExchangeConstantsMarketStatusColumns.ID.value] ) if not (maker_string and taker_string): self.logger.error( f"Missing fee details for {symbol} in fetched {self.exchange_manager.exchange_name} fees " f"(using {market[trading_enums.ExchangeConstantsMarketStatusColumns.ID.value]} as market id)" ) continue result[symbol] = { trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value: self.client.parse_number(ccxt.Precise.string_div(maker_string, '100')), trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value: self.client.parse_number(ccxt.Precise.string_div(taker_string, '100')), trading_enums.ExchangeConstantsMarketPropertyColumns.FEE_SIDE.value: market.get( trading_enums.ExchangeConstantsMarketPropertyColumns.FEE_SIDE.value, DEFAULT_FEE_SIDE ) # don't keep unecessary info # 'info': fees, # 'symbol': symbol, # 'percentage': True, # 'tierBased': True, } fees_by_tier[tier] = result exchange_name = self.exchange_manager.exchange_name _EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME[exchange_name] = fees_by_tier _REFRESHED_EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME[exchange_name] = True sample = { tier: next(iter(fees.values())) if fees else None for tier, fees in fees_by_tier.items() } fee_pairs = list(fees_by_tier[next(iter(fees_by_tier))]) if fees_by_tier else [] self.logger.info( f"Refreshed {exchange_name} fee tiers. Sample: {sample}. {len(sample)} tiers: {list(sample)} " f"over {len(fee_pairs)} pairs: {fee_pairs}. Using fee tiers " f"{self._get_fee_tiers(self.exchange_manager.exchange, not self.exchange_manager.is_backtesting).value}." ) @classmethod def simulator_connector_calculate_fees_factory(cls, exchange_name: str, tiers: FeeTiers): # same signature as ExchangeSimulatorConnector.calculate_fees def simulator_connector_calculate_fees( symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, taker_or_maker: str ): # no try/catch: should raise in case fees are not available return cls._calculate_fetched_fees( exchange_name, tiers, symbol, order_type, quantity, price, taker_or_maker ) return simulator_connector_calculate_fees @classmethod def simulator_connector_get_fees_factory(cls, exchange_name: str, tiers: FeeTiers): # same signature as ExchangeSimulatorConnector.get_fees def simulator_connector_get_fees(symbol): return cls._get_fees(exchange_name, tiers, symbol) return simulator_connector_get_fees @classmethod def register_simulator_connector_fee_methods( cls, exchange_name: str, simulator_connector: exchanges.ExchangeSimulatorConnector ): # only called in backtesting # overrides exchange simulator connector calculate_fees and get_fees to use fetched fees instead fee_tiers = cls._get_fee_tiers(None, False) simulator_connector.calculate_fees = cls.simulator_connector_calculate_fees_factory(exchange_name, fee_tiers) simulator_connector.get_fees = cls.simulator_connector_get_fees_factory(exchange_name, fee_tiers) def calculate_fees( self, symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, taker_or_maker: str ): # only called in live trading is_real_trading = not self.exchange_manager.is_backtesting # consider live trading as real to use basic tier try: fee_tiers = self._get_fee_tiers(self.exchange_manager.exchange, is_real_trading) return self._calculate_fetched_fees( self.exchange_manager.exchange_name, fee_tiers, symbol, order_type, quantity, price, taker_or_maker ) except errors.MissingFeeDetailsError as err: self.logger.error(f"Error calculating fees: {err}. Using default ccxt values") # live trading: can fallback to ccxt default values as the ccxt client exists and is initialized return super().calculate_fees(symbol, order_type, quantity, price, taker_or_maker) def get_fees(self, symbol): # only called in live trading try: is_real_trading = not self.exchange_manager.is_backtesting # consider live trading as real to use basic tier fee_tiers = self._get_fee_tiers(self.exchange_manager.exchange, is_real_trading) return self._get_fees(self.exchange_manager.exchange_name, fee_tiers, symbol) except errors.MissingFeeDetailsError: if _EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME.get(self.exchange_manager.exchange_name): self.logger.error(f"Missing {self.exchange_manager.exchange_name} {symbol} fee details, using default value") else: self.logger.warning(f"Missing all {self.exchange_manager.exchange_name} fee details, using ccxt default values") market = self.get_market_status(symbol, with_fixer=False) # use default ccxt values return { trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value: market[ trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value ], trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value: market[ trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value ], trading_enums.ExchangeConstantsMarketPropertyColumns.FEE_SIDE.value: market.get( trading_enums.ExchangeConstantsMarketPropertyColumns.FEE_SIDE.value, DEFAULT_FEE_SIDE ), trading_enums.ExchangeConstantsMarketPropertyColumns.FEE.value: market.get( trading_enums.ExchangeConstantsMarketPropertyColumns.FEE.value, trading_constants.CONFIG_DEFAULT_FEES ) } @classmethod def _calculate_fetched_fees( cls, exchange_name: str, fee_tiers: FeeTiers, symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, taker_or_maker: str ): # will raise MissingFeeDetailsError if fees details are not available fee_details = cls._get_fetched_fees(exchange_name, fee_tiers, symbol) fee_side = fee_details[trading_enums.ExchangeConstantsMarketPropertyColumns.FEE_SIDE.value] side = exchanges.get_order_side(order_type) # similar as ccxt.Exchange.calculate_fee if fee_side == trading_enums.ExchangeFeeSides.GET.value: # the fee is always in the currency you get use_quote = side == trading_enums.TradeOrderSide.SELL.value elif fee_side == trading_enums.ExchangeFeeSides.GIVE.value: # the fee is always in the currency you give use_quote = side == trading_enums.TradeOrderSide.BUY.value else: # the fee is always in feeSide currency use_quote = fee_side == trading_enums.ExchangeFeeSides.QUOTE.value parsed_symbol = symbols_utils.parse_symbol(symbol) if use_quote: cost = quantity * price fee_currency = parsed_symbol.quote else: cost = quantity fee_currency = parsed_symbol.base fee_rate = decimal.Decimal(str(fee_details[taker_or_maker])) fee_cost = cost * fee_rate return { trading_enums.FeePropertyColumns.TYPE.value: taker_or_maker, trading_enums.FeePropertyColumns.CURRENCY.value: fee_currency, trading_enums.FeePropertyColumns.RATE.value: float(fee_rate), trading_enums.FeePropertyColumns.COST.value: float(fee_cost), } @classmethod def _get_fee_tiers(cls, rest_exchange: typing.Optional[exchanges.RestExchange], is_real_trading: bool): if ( rest_exchange and isinstance(rest_exchange, hollaex) and (fee_tiers := rest_exchange.get_configured_fee_tiers()) ): return fee_tiers # default to basic tier return FeeTiers.BASIC if is_real_trading else FeeTiers.VIP @classmethod def _get_fees(cls, exchange_name: str, tiers: FeeTiers, symbol: str): return { ** cls._get_fetched_fees(exchange_name, tiers, symbol), ** { # todo update this if withdrawal fees become relevant trading_enums.ExchangeConstantsMarketPropertyColumns.FEE.value: trading_constants.CONFIG_DEFAULT_FEES } } @classmethod def _get_default_fee_symbol(cls, exchange: str): try: exchange_fees = _EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME[exchange] first_fee_tier = next(iter(exchange_fees.values())) return next(iter(first_fee_tier)) except (StopIteration, KeyError) as err: raise errors.MissingFeeDetailsError( f"No available {exchange} fee details {err} ({err.__class__.__name__})" ) from err @classmethod def _get_fetched_fees(cls, exchange: str, tier_to_use: FeeTiers, symbol: str): try: exchange_fees = _EXCHANGE_FEE_TIERS_BY_EXCHANGE_NAME[exchange] except KeyError as err: raise errors.MissingFeeDetailsError(f"No available {exchange} fee details") from err try: return exchange_fees[tier_to_use.value][symbol] except KeyError as err: if not exchange_fees: # mssing exchange fees, should not happen raise errors.MissingFeeDetailsError( f"Unexpected: missing {exchange} fee details" ) from err if symbol not in exchange_fees[FeeTiers.BASIC.value]: default_fee_symbol = cls._get_default_fee_symbol(exchange) if symbol == default_fee_symbol: raise errors.MissingFeeDetailsError( f"No available {exchange} {tier_to_use.name} {symbol} fee details" ) from err logging.get_logger(cls.__name__).error( f"No {symbol} fee tier info on {exchange}: using {default_fee_symbol} fees as default value" ) return cls._get_fetched_fees(exchange, tier_to_use, default_fee_symbol) if tier_to_use.value not in exchange_fees and FeeTiers.BASIC.value in tier_to_use.value: # symbol is in exchange_fees[FeeTiers.BASIC.value] or previous condition would have triggered logging.get_logger(cls.__name__).info( f"Falling back on {FeeTiers.BASIC.name} fee tier for {exchange}: no {tier_to_use.name} value" ) return exchange_fees[FeeTiers.BASIC.value][symbol] raise errors.MissingFeeDetailsError( f"No available {exchange} {tier_to_use.name} {symbol} fee details" ) from err class hollaex(exchanges.RestExchange): DESCRIPTION = "" DEFAULT_CONNECTOR_CLASS = hollaexConnector FIX_MARKET_STATUS = True BASE_REST_API = "api.hollaex.com" REST_KEY = "rest" FEE_TIERS_KEY = "fee_tiers" HAS_WEBSOCKETS_KEY = "has_websockets" REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees SUPPORT_FETCHING_CANCELLED_ORDERS = False IS_SKIPPING_EMPTY_CANDLES_IN_OHLCV_FETCH = True # STOP_PRICE is used in ccxt/hollaex instead of default STOP_LOSS_PRICE STOP_LOSS_CREATE_PRICE_PARAM = ccxt_enums.ExchangeOrderCCXTUnifiedParams.STOP_PRICE.value STOP_LOSS_EDIT_PRICE_PARAM = STOP_LOSS_CREATE_PRICE_PARAM # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request # not supported or need custom mechanics with batch orders trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, # still broken ccxt 4.5.8: stop param is ignored by exchange because it's sent as a string instead of float. Converting it to flaat fails the signature trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } DEFAULT_MAX_LIMIT = 500 EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # '"message":"Access denied: Unauthorized Access. This key does not have the right permissions to access this endpoint"' ("permissions to access",), ] EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # {"message":"Access denied: Unauthorized Access. # The IP address you are reaching this endpoint through is not allowed to access this endpoint"} ("the ip address", "is not allowed"), ] EXCHANGE_MAX_ORDERS_FOR_MARKET_REACHED_ERRORS: typing.List[typing.Iterable[str]] = [ # "hollaex {"message":"You are only allowed to have maximum 50 active orders per market"}" ("maximum", "active orders", "per market"), ] def __init__( self, config, exchange_manager, exchange_config_by_exchange: typing.Optional[dict[str, dict]], connector_class=None ): super().__init__(config, exchange_manager, exchange_config_by_exchange, connector_class=connector_class) self.exchange_manager.rest_only = self.exchange_manager.rest_only \ or not self.tentacle_config.get( self.HAS_WEBSOCKETS_KEY, not self.exchange_manager.rest_only ) def get_adapter_class(self): return HollaexCCXTAdapter @classmethod def init_user_inputs_from_class(cls, inputs: dict) -> None: """ Called at constructor, should define all the exchange's user inputs. """ cls.CLASS_UI.user_input( cls.REST_KEY, commons_enums.UserInputTypes.TEXT, f"https://{cls.BASE_REST_API}", inputs, title=f"Address of the Hollaex based exchange API (similar to https://{cls.BASE_REST_API})" ) cls.CLASS_UI.user_input( cls.FEE_TIERS_KEY, commons_enums.UserInputTypes.OPTIONS, FeeTiers.BASIC.value, inputs, title=f"Fee tiers to use for the exchange. Used to predict fees.", options=[tier.value for tier in FeeTiers] ) cls.CLASS_UI.user_input( cls.HAS_WEBSOCKETS_KEY, commons_enums.UserInputTypes.BOOLEAN, True, inputs, title=f"Use websockets feed. To enable only when websockets are supported by the exchange." ) def get_additional_connector_config(self): return { ccxt_enums.ExchangeColumns.URLS.value: self.get_patched_urls(self.get_api_url()) } def get_api_url(self): return self.tentacle_config[self.REST_KEY] def get_configured_fee_tiers(self) -> typing.Optional[FeeTiers]: if tiers := self.tentacle_config.get(self.FEE_TIERS_KEY): return FeeTiers(tiers) return None @classmethod def get_custom_url_config(cls, tentacle_config: dict, exchange_name: str) -> dict: if details := cls.get_exchange_details(tentacle_config, exchange_name): return { ccxt_enums.ExchangeColumns.URLS.value: cls.get_patched_urls(details.api) } return {} @classmethod def get_exchange_details(cls, tentacle_config, exchange_name) -> typing.Optional[exchanges.ExchangeDetails]: return None @classmethod def get_patched_urls(cls, api_url: str): urls = ccxt.hollaex().urls custom_urls = { ccxt_enums.ExchangeColumns.API.value: { cls.REST_KEY: api_url } } urls.update(custom_urls) return urls @classmethod def get_name(cls): return 'hollaex' @classmethod def is_configurable(cls): return True def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: signature_identifier = "api-signature" return bool( headers and signature_identifier in headers ) def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: # (30/06/2025: Error 1010 - You are only allowed to have maximum 50 active orders per market) return 50 async def get_account_id(self, **kwargs: dict) -> str: with self.connector.error_describer(): user_info = await self.connector.client.private_get_user() return user_info["id"] async def get_closed_orders(self, symbol: str = None, since: int = None, limit: int = None, **kwargs: dict) -> list: # get_closed_orders sometimes does not return orders use _get_closed_orders_from_my_recent_trades in this case return ( await super().get_closed_orders(symbol=symbol, since=since, limit=limit, **kwargs) or await self._get_closed_orders_from_my_recent_trades( symbol=symbol, since=since, limit=limit, **kwargs ) ) class HollaexCCXTAdapter(exchanges.CCXTAdapter): def fix_order(self, raw, symbol=None, **kwargs): raw_order_info = raw[ccxt_enums.ExchangePositionCCXTColumns.INFO.value] # average is not supported by ccxt fixed = super().fix_order(raw, symbol=symbol, **kwargs) if not fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] and "average" in raw_order_info: fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = raw_order_info.get("average", 0) if fixed[ccxt_enums.ExchangeOrderCCXTColumns.TRIGGER_PRICE.value]: order_type = trading_enums.TradeOrderType.STOP_LOSS.value # todo uncomment when stop loss limit are supported # if fixed[ccxt_enums.ExchangeOrderCCXTColumns.PRICE.value] is None: # order_type = trading_enums.TradeOrderType.STOP_LOSS.value fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = order_type # fees are usually not in order, but if they are, fix them as ccxt is not parsing them self._fix_fees(raw_order_info, fixed) return fixed def _fix_fees(self, info, fixed): if (fee_coin := info.get("fee_coin")) and fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.FEE.value): # fee_coin is wrongly overwritten by ccxt as quote currency, used fetched value fixed[trading_enums.ExchangeConstantsOrderColumns.FEE.value][ trading_enums.FeePropertyColumns.CURRENCY.value ] = fee_coin.upper() def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Exchange/hollaex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["hollaex"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/hollaex/resources/hollaex.md ================================================ Hollaex is a basic RestExchange adaptation for Hollaex exchange. Change the api url to connect to a specific hollaex exchange ================================================ FILE: Trading/Exchange/hollaex/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/hollaex_autofilled/__init__.py ================================================ from .hollaex_autofilled_exchange import HollaexAutofilled ================================================ FILE: Trading/Exchange/hollaex_autofilled/config/HollaexAutofilled.json ================================================ { "auto_filled": {} } ================================================ FILE: Trading/Exchange/hollaex_autofilled/hollaex_autofilled_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import cachetools import aiohttp import typing import asyncio import requests.utils import octobot_commons.logging as commons_logging import octobot_commons.constants import octobot_commons.html_util as html_util import octobot_trading.exchanges as exchanges import octobot_trading.errors as errors import octobot_tentacles_manager.api from ..hollaex.hollaex_exchange import hollaex _EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL: dict[str, dict] = {} # refresh exchange config every day but don't delete outdated info, only replace it with updated ones _REFRESHED_EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL : cachetools.TTLCache[str, bool] = cachetools.TTLCache( maxsize=50, ttl=octobot_commons.constants.DAYS_TO_SECONDS ) class HollaexAutofilled(hollaex): HAS_FETCHED_DETAILS = True URL_KEY = "url" AUTO_FILLED_KEY = "auto_filled" WEBSOCKETS_KEY = "websockets" KIT_PATH = "/kit" V2_KIT_PATH = f"v2{KIT_PATH}" MAX_RATE_LIMIT_ATTEMPTS = 60 # fetch over 3 minutes, every 3s (we can't start the bot if the kit request fails) RATE_LIMIT_SLEEP_TIME = 3 @classmethod def supported_autofill_exchanges(cls, tentacle_config): return list(tentacle_config[cls.AUTO_FILLED_KEY]) if tentacle_config else [] @classmethod def init_user_inputs_from_class(cls, inputs: dict) -> None: pass @classmethod async def get_autofilled_exchange_details(cls, aiohttp_session, tentacle_config, exchange_name): kit_details = await aiohttp_session.get(HollaexAutofilled._get_kit_url(tentacle_config, exchange_name)) return HollaexAutofilled._parse_autofilled_exchange_details( tentacle_config, await kit_details.json(), exchange_name ) def _apply_fetched_details(self, config, exchange_manager): self._apply_config(self.get_exchange_details(self.tentacle_config, exchange_manager.exchange_name)) @classmethod async def fetch_exchange_config( cls, exchange_config_by_exchange: typing.Optional[dict[str, dict]], exchange_manager ): hollaex_based_exchange_identifier = cls.get_name() if not exchange_config_by_exchange: # no override, try using exchange_manager.tentacles_setup_config exchange_config_by_exchange = { hollaex_based_exchange_identifier: ( octobot_tentacles_manager.api.get_tentacle_config(exchange_manager.tentacles_setup_config, cls) ) } if not exchange_config_by_exchange or hollaex_based_exchange_identifier not in exchange_config_by_exchange: raise KeyError( f"{hollaex_based_exchange_identifier} has to be in exchange_config_by_exchange. " f"{exchange_config_by_exchange=}" ) tentacle_config = exchange_config_by_exchange[hollaex_based_exchange_identifier] await cls._cached_fetch_autofilled_config(tentacle_config, exchange_manager.exchange_name) @classmethod def _get_user_agent(cls): return requests.utils.default_user_agent() @classmethod def _get_headers(cls): return { # same as CCXT 'User-Agent': cls._get_user_agent(), "Accept-Encoding": "gzip, deflate" } @classmethod async def _cached_fetch_autofilled_config(cls, tentacle_config, exchange_name) -> dict: try: exchange_kit_url = cls._get_kit_url(tentacle_config, exchange_name) except KeyError: raise errors.NotSupported(f"{exchange_name} is not supported by {cls.get_name()}") if exchange_kit_url in _REFRESHED_EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL: return _EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL[exchange_kit_url] commons_logging.get_logger(cls.get_name()).info( f"Fetching {exchange_name} HollaEx kit from {exchange_kit_url}" ) async with aiohttp.ClientSession(headers=cls._get_headers()) as session: _EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL[exchange_kit_url] = await cls._retry_fetch_when_rate_limit( session, exchange_kit_url ) _REFRESHED_EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL[exchange_kit_url] = True return _EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL[exchange_kit_url] @classmethod async def _retry_fetch_when_rate_limit(cls, session, url): try: for attempt in range(cls.MAX_RATE_LIMIT_ATTEMPTS): async with session.get(url) as response: if response.status < 300: return await response.json() elif response.status in (403, 429) or "has banned your IP address" in (await response.text()): # rate limit: sleep and retry commons_logging.get_logger(cls.get_name()).warning( f"Error when fetching {url}: {response.status}. Retrying in {cls.RATE_LIMIT_SLEEP_TIME} seconds" ) await asyncio.sleep(cls.RATE_LIMIT_SLEEP_TIME) else: # unexpected error response.raise_for_status() commons_logging.get_logger(cls.get_name()).error( f"Error when fetching {url}: {response.status}. Max attempts ({cls.MAX_RATE_LIMIT_ATTEMPTS}) reached. " f"Error text: {await response.text()}" ) response.raise_for_status() except aiohttp.ClientResponseError as err: if err.status == 404: raise errors.FailedRequest(f"{url} returned 404: not found: {err.message}") from err raise # forward unexpected errors except aiohttp.ClientConnectionError as err: raise errors.NetworkError( f"Failed to execute request: {err.__class__.__name__}: {html_util.get_html_summary_if_relevant(err)}" ) from err def _supports_autofill(self, exchange_name): try: self._get_kit_url(self.tentacle_config, exchange_name) return True except KeyError: return False @classmethod def _get_kit_url(cls, tentacle_config, exchange_name) -> str: exchange_kit_url = HollaexAutofilled._get_autofilled_config(tentacle_config, exchange_name)[cls.URL_KEY] if not exchange_kit_url.endswith(cls.KIT_PATH) and not exchange_kit_url.endswith("/"): exchange_kit_url = f"{exchange_kit_url}/" if not exchange_kit_url.endswith(cls.KIT_PATH): exchange_kit_url = f"{exchange_kit_url}{cls.V2_KIT_PATH}" return exchange_kit_url @classmethod def _has_websocket(cls, tentacle_config, exchange_name): return HollaexAutofilled._get_autofilled_config(tentacle_config, exchange_name).get(cls.WEBSOCKETS_KEY, False) @classmethod def _get_autofilled_config(cls, tentacle_config, exchange_name): return tentacle_config[cls.AUTO_FILLED_KEY][exchange_name] @classmethod def get_exchange_details(cls, tentacle_config, exchange_name) -> typing.Optional[exchanges.ExchangeDetails]: return cls._parse_autofilled_exchange_details( tentacle_config, _EXCHANGE_REMOTE_CONFIG_BY_EXCHANGE_KIT_URL[ cls._get_kit_url(tentacle_config, exchange_name) ], exchange_name ) @classmethod def _parse_autofilled_exchange_details(cls, tentacle_config, kit_details, exchange_name): """ use /kit to fill in exchange details format: { "api_name": "BitcoinRD Exchange", "black_list_countries": [], "captcha": {}, "color": { "Black": { "base_background": "#000000", ... } }, "defaults": { "country": "DO", "language": "es", "theme": "dark" }, "description": "Primer Exchange 100% Dominicano.", "dust": { "maker_id": 1, "quote": "xht", "spread": 0 }, "email_verification_required": true, "features": { "chat": false, ... }, "icons": { "dark": { "DOP_ICON": "https://bitholla.s3.ap-northeast-2.amazonaws.com/exchange/bitcoinrdexchange/DOP_ICON__dark___1631209668172.png", ... }, "white": { "EXCHANGE_FAV_ICON": "https://bitholla.s3.ap-northeast-2.amazonaws.com/exchange/bitcoinrdexchange/EXCHANGE_FAV_ICON__white___1615349464540.png", ... } }, "info": { "active": true, "collateral_level": "member", "created_at": "2021-03-09T14:12:49.012Z", "exchange_id": 1512, "expiry": "2023-08-27T23:59:59.000Z", "initialized": true, "is_trial": false, "name": "bitcoinrdexchange", "period": "year", "plan": "fiat", "status": true, "type": "Cloud", "url": "https://api.bitcoinrd.do", "user_id": 3536 }, "injected_html": { "body": "", "head": "" }, "injected_values": [], "interface": {}, "links": { "api": "https://api.bitcoinrd.do", "contact": "", "facebook": "", "github": "", "helpdesk": "mailto:soporte@bitcoinrd.do", "information": "", "instagram": "", "linkedin": "", "privacy": "https://bitcoinrd.online/privacy-policy/", "referral_label": "Powered by BitcoinRD", "referral_link": "https://bitcoinrd.online/", "section_1": { "content": { "instagram": "https://www.instagram.com/bitcoinrd/" }, "header": { "column_header_1": "RRSS" } }, "section_2": "", "telegram": "", "terms": "https://bitcoinrd.online/terms/", "twitter": "", "website": "", "whitepaper": "" }, "logo_image": "https://bitholla.s3.ap-northeast-2.amazonaws.com/exchange/bitcoinrdexchange/EXCHANGE_LOGO__dark___1615345052424.png", "meta": { "default_digital_assets_sort": "change", ... }, "versions": { "color": "color-1681492596812", ... } }, "native_currency": "usdt", "new_user_is_activated": true, "offramp": {...}, "onramp": {...}, "setup_completed": true, "strings": { "en": { ...} }, "title": "", "user_meta": {}, "user_payments": {}, "valid_languages": "en,es,fr" } """ return exchanges.ExchangeDetails( exchange_name, kit_details.get("api_name", exchange_name), kit_details["links"].get("referral_link", ""), kit_details["info"]["url"], # required (API url) kit_details.get("logo_image", ""), HollaexAutofilled._has_websocket( tentacle_config, exchange_name ) ) def _apply_config(self, autofilled_exchange_details: exchanges.ExchangeDetails): self.logger = commons_logging.get_logger(autofilled_exchange_details.name) self.tentacle_config[self.REST_KEY] = autofilled_exchange_details.api self.tentacle_config[self.HAS_WEBSOCKETS_KEY] = autofilled_exchange_details.has_websocket @classmethod def is_supporting_sandbox(cls) -> bool: return False @classmethod def get_rest_name(cls, exchange_manager): return hollaex.get_name() def get_associated_websocket_exchange_name(self): return self.get_name() @classmethod def get_name(cls): return cls.__name__ ================================================ FILE: Trading/Exchange/hollaex_autofilled/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["HollaexAutofilled"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/hollaex_autofilled/resources/hollaex_autofilled.md ================================================ Basic RestExchange adaptation for auto filled exchange using HollaEx ================================================ FILE: Trading/Exchange/hollaex_autofilled/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/hollaex_autofilled_websocket_feed/__init__.py ================================================ from .hollaex_autofilled_websocket import HollaexAutofilledCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/hollaex_autofilled_websocket_feed/hollaex_autofilled_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from ..hollaex_autofilled.hollaex_autofilled_exchange import HollaexAutofilled from ..hollaex_websocket_feed.hollaex_websocket import HollaexCCXTWebsocketConnector class HollaexAutofilledCCXTWebsocketConnector(HollaexCCXTWebsocketConnector): def _get_logger_name(self): return f"WebSocket - {self._get_visible_name()}" def _get_visible_name(self): return self.exchange_manager.exchange_name @classmethod def get_name(cls): return HollaexAutofilled.get_name() def get_feed_name(self): return HollaexCCXTWebsocketConnector.get_name() ================================================ FILE: Trading/Exchange/hollaex_autofilled_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["HollaexAutofilledCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/hollaex_websocket_feed/__init__.py ================================================ from .hollaex_websocket import HollaexCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/hollaex_websocket_feed/hollaex_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import ccxt.pro as ccxt_pro import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums from ..hollaex.hollaex_exchange import hollaex class HollaexCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): BASE_WS_API = f"{hollaex.BASE_REST_API}/stream" EXCHANGE_FEEDS = { Feeds.TRADES: Feeds.UNSUPPORTED.value, Feeds.KLINE: Feeds.UNSUPPORTED.value, Feeds.TICKER: Feeds.UNSUPPORTED.value, Feeds.CANDLE: Feeds.UNSUPPORTED.value, } def _create_client(self, force_unauth=False): if not self.additional_config: additional_connector_config = self.exchange_manager.exchange.get_additional_connector_config() try: self._update_urls(additional_connector_config) # use rest exchange additional config if any self.additional_config = additional_connector_config except KeyError as err: self.logger.error(f"Error when updating exchange url: {err}") super()._create_client() def _update_urls(self, additional_connector_config): rest_url = additional_connector_config[ccxt_enums.ExchangeColumns.URLS.value][ ccxt_enums.ExchangeColumns.API.value ][ccxt_enums.ExchangeColumns.REST.value] if hollaex.BASE_REST_API not in rest_url: current_ws_url = ccxt_pro.hollaex().describe()[ccxt_enums.ExchangeColumns.URLS.value][ ccxt_enums.ExchangeColumns.API.value ][ccxt_enums.ExchangeColumns.WEBSOCKET.value] custom_url = rest_url.split("https://")[1] additional_connector_config[ccxt_enums.ExchangeColumns.URLS.value][ ccxt_enums.ExchangeColumns.API.value ][ccxt_enums.ExchangeColumns.WEBSOCKET.value] = current_ws_url.replace(hollaex.BASE_REST_API, custom_url) @classmethod def get_name(cls): return hollaex.get_name() ================================================ FILE: Trading/Exchange/hollaex_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["HollaexCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/htx/__init__.py ================================================ from .htx_exchange import Htx ================================================ FILE: Trading/Exchange/htx/htx_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants class Htx(exchanges.RestExchange): FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server @classmethod def get_name(cls): return 'htx' def get_adapter_class(self): return HtxCCXTAdapter def get_additional_connector_config(self): # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders return { ccxt_constants.CCXT_OPTIONS: { "createMarketBuyOrderRequiresPrice": False # disable quote conversion } } async def get_symbol_prices(self, symbol, time_frame, limit: int = 500, **kwargs: dict): history_param = "useHistoricalEndpointForSpot" if limit and history_param not in kwargs: # required to handle limits kwargs[history_param] = False return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) class HtxCCXTAdapter(exchanges.CCXTAdapter): def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) self.adapt_amount_from_filled_or_cost(fixed) return fixed ================================================ FILE: Trading/Exchange/htx/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Htx"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/htx/resources/htx.md ================================================ HTX is a basic RestExchange adaptation for HTX exchange. ================================================ FILE: Trading/Exchange/htx_websocket_feed/__init__.py ================================================ from .htx_websocket import HtxCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/htx_websocket_feed/htx_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.htx.htx_exchange as htx_exchange class HtxCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return htx_exchange.Htx.get_name() def get_adapter_class(self, adapter_class): return htx_exchange.HtxCCXTAdapter ================================================ FILE: Trading/Exchange/htx_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["HtxCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/huobi/__init__.py ================================================ from .huobi_exchange import Huobi ================================================ FILE: Trading/Exchange/huobi/huobi_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.htx.htx_exchange as htx_exchange class Huobi(htx_exchange.Htx): # kept for legacy support (users using huobi instead of HTX) @classmethod def get_name(cls): return 'huobi' ================================================ FILE: Trading/Exchange/huobi/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Huobi"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/huobi/resources/huobi.md ================================================ Huobi is a basic RestExchange adaptation for Huobi exchange. ================================================ FILE: Trading/Exchange/huobi_websocket_feed/__init__.py ================================================ from .huobi_websocket import HuobiCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/huobi_websocket_feed/huobi_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.huobi.huobi_exchange as huobi_exchange import tentacles.Trading.Exchange.htx_websocket_feed.htx_websocket as htx_websocket class HuobiCCXTWebsocketConnector(htx_websocket.HtxCCXTWebsocketConnector): # kept for legacy support (users using huobi instead of HTX) @classmethod def get_name(cls): return huobi_exchange.Huobi.get_name() ================================================ FILE: Trading/Exchange/huobi_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["HuobiCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/huobi_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/huobi_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.exchanges_test_tools as exchanges_test_tools import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...huobi_websocket_feed import HuobiCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() exchange_manager_instance = await exchanges_test_tools.create_test_exchange_manager( config=config, exchange_name=HuobiCryptofeedWebsocketConnector.get_name()) await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=HuobiCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_MINUTE, commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.RECENT_TRADES_CHANNEL.value, }, time_before_assert=20 ) await exchanges_test_tools.stop_test_exchange_manager(exchange_manager_instance) ================================================ FILE: Trading/Exchange/hyperliquid/__init__.py ================================================ from .hyperliquid_exchange import Hyperliquid ================================================ FILE: Trading/Exchange/hyperliquid/hyperliquid_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants class HyperliquidConnector(exchanges.CCXTConnector): def _client_factory( self, force_unauth, keys_adapter: typing.Callable[[exchanges.ExchangeCredentialsData], exchanges.ExchangeCredentialsData]=None ) -> tuple: return super()._client_factory(force_unauth, keys_adapter=self._keys_adapter) def _keys_adapter(self, creds: exchanges.ExchangeCredentialsData) -> exchanges.ExchangeCredentialsData: # use api key and secret as wallet address and private key creds.wallet_address = creds.api_key creds.private_key = creds.secret creds.api_key = creds.secret = None return creds class Hyperliquid(exchanges.RestExchange): DESCRIPTION = "" DEFAULT_CONNECTOR_CLASS = HyperliquidConnector FIX_MARKET_STATUS = True REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees # should be fetched using recent trades. @classmethod def get_name(cls): return 'hyperliquid' def get_adapter_class(self): return HyperLiquidCCXTAdapter def get_additional_connector_config(self): return { ccxt_constants.CCXT_OPTIONS: { "fetchMarkets": { "types": ["spot"], # only hyperliquid spot markets are supported } } } class HyperLiquidCCXTAdapter(exchanges.CCXTAdapter): def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed def fix_market_status(self, raw, remove_price_limits=False, **kwargs): fixed = super().fix_market_status(raw, remove_price_limits=remove_price_limits, **kwargs) if not fixed: return fixed # hyperliquid min cost should be increased by 10% (a few cents above min cost is refused) limits = fixed[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS.value] limits[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST.value][ trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST_MIN.value ] = limits[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST.value][ trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST_MIN.value ] * 1.1 return fixed ================================================ FILE: Trading/Exchange/hyperliquid/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Hyperliquid"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/hyperliquid/resources/hyperliquid.md ================================================ Hyperliquid is a basic RestExchange adaptation for Hyperliquid exchange. ================================================ FILE: Trading/Exchange/hyperliquid/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/hyperliquid_websocket_feed/__init__.py ================================================ from .hyperliquid_websocket import HyperliquidCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/hyperliquid_websocket_feed/hyperliquid_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.hyperliquid.hyperliquid_exchange as hyperliquid_exchange class HyperliquidCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): USE_REST_CONNECTOR_ADDITIONAL_CONFIG = True EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return hyperliquid_exchange.Hyperliquid.get_name() def get_adapter_class(self, adapter_class): return hyperliquid_exchange.HyperLiquidCCXTAdapter ================================================ FILE: Trading/Exchange/hyperliquid_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["HyperliquidCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/kraken/__init__.py ================================================ from .kraken_exchange import Kraken ================================================ FILE: Trading/Exchange/kraken/kraken_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_trading.exchanges as exchanges import octobot_trading.errors import octobot_trading.enums as trading_enums class Kraken(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True RECENT_TRADE_FIXED_LIMIT = 1000 ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server def __init__( self, config, exchange_manager, exchange_config_by_exchange: typing.Optional[dict[str, dict]], connector_class=None ): super().__init__(config, exchange_manager, exchange_config_by_exchange, connector_class=connector_class) self.logger.error("Kraken is not providing free and used data for account balance. " "OctoBot wont be able to manage a real portfolio correctly.") @classmethod def get_name(cls): return 'kraken' def get_adapter_class(self): return KrakenCCXTAdapter async def get_recent_trades(self, symbol, limit=RECENT_TRADE_FIXED_LIMIT, **kwargs): if limit is not None and limit != self.RECENT_TRADE_FIXED_LIMIT: self.logger.debug(f"Trying to get_recent_trades with limit != {self.RECENT_TRADE_FIXED_LIMIT} : ({limit})") limit = self.RECENT_TRADE_FIXED_LIMIT return await super().get_recent_trades(symbol=symbol, limit=limit, **kwargs) async def get_order_book(self, symbol, limit=5, **kwargs): # suggestion from https://github.com/ccxt/ccxt/issues/8135#issuecomment-748520283 try: return await self.connector.client.fetch_l2_order_book(symbol, limit=limit, params=kwargs) except Exception as e: raise octobot_trading.errors.FailedRequest(f"Failed to get_order_book {e}") async def get_symbol_prices(self, symbol, time_frame, limit: int = None, **kwargs: dict): # ohlcv limit is not working as expected, limit is doing [:-limit] but we want [-limit:] candles = await super().get_symbol_prices(symbol=symbol, time_frame=time_frame, limit=limit, **kwargs) if limit: return candles[-limit:] return candles class KrakenCCXTAdapter(exchanges.CCXTAdapter): def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Exchange/kraken/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Kraken"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/kraken/resources/kraken.md ================================================ Kraken is a basic RestExchange adaptation for Kraken exchange. ================================================ FILE: Trading/Exchange/kraken/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/kraken_websocket_feed/__init__.py ================================================ from .kraken_websocket import KrakenCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/kraken_websocket_feed/kraken_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.kraken.kraken_exchange as kraken_exchange class KrakenCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return kraken_exchange.Kraken.get_name() ================================================ FILE: Trading/Exchange/kraken_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["KrakenCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/kraken_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/kraken_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.exchanges_test_tools as exchanges_test_tools import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...kraken_websocket_feed import KrakenCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() exchange_manager_instance = await exchanges_test_tools.create_test_exchange_manager( config=config, exchange_name=KrakenCryptofeedWebsocketConnector.get_name()) await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=KrakenCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_MINUTE, commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.RECENT_TRADES_CHANNEL.value, channels_name.OctoBotTradingChannelsName.TICKER_CHANNEL.value, channels_name.OctoBotTradingChannelsName.OHLCV_CHANNEL.value, channels_name.OctoBotTradingChannelsName.KLINE_CHANNEL.value, }, time_before_assert=20 ) await exchanges_test_tools.stop_test_exchange_manager(exchange_manager_instance) ================================================ FILE: Trading/Exchange/kucoin/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .kucoin_exchange import Kucoin ================================================ FILE: Trading/Exchange/kucoin/kucoin_exchange.py ================================================ # Drakkar-Software OctoBot-Private-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import time import decimal import typing import ccxt import octobot_commons.constants as commons_constants import octobot_commons.logging as logging import octobot_trading.errors import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.ccxt_connector as ccxt_connector import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.exchanges.connectors.ccxt.ccxt_client_util as ccxt_client_util import octobot_trading.constants as constants import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot.community _CACHED_CONFIRMED_FEES_BY_SYMBOL = {} def _kucoin_retrier(f): async def kucoin_retrier_wrapper(*args, **kwargs): last_error = None for i in range(0, Kucoin.FAKE_DDOS_ERROR_INSTANT_RETRY_COUNT): try: return await f(*args, **kwargs) except (octobot_trading.errors.FailedRequest, ccxt.ExchangeError) as err: last_error = err rest_exchange = args[0] # self if (rest_exchange.connector is not None) and \ rest_exchange.connector.client.last_http_response and \ Kucoin.INSTANT_RETRY_ERROR_CODE in rest_exchange.connector.client.last_http_response: # should retry instantly, error on kucoin side # see https://github.com/Drakkar-Software/OctoBot/issues/2000 logging.get_logger(Kucoin.get_name()).debug( f"{Kucoin.INSTANT_RETRY_ERROR_CODE} error on {f.__name__}(args={args[1:]} kwargs={kwargs}) " f"request, retrying now. Attempt {i+1} / {Kucoin.FAKE_DDOS_ERROR_INSTANT_RETRY_COUNT}, " f"error: {err} ({last_error.__class__.__name__})." ) else: raise last_error = last_error or RuntimeError("Unknown Kucoin error") # to be able to "raise from" in next line raise octobot_trading.errors.FailedRequest( f"Failed Kucoin request after {Kucoin.FAKE_DDOS_ERROR_INSTANT_RETRY_COUNT} " f"retries on {f.__name__}(args={args[1:]} kwargs={kwargs}) due " f"to {Kucoin.INSTANT_RETRY_ERROR_CODE} error code. " f"Last error: {last_error} ({last_error.__class__.__name__})" ) from last_error return kucoin_retrier_wrapper class KucoinConnector(ccxt_connector.CCXTConnector): @_kucoin_retrier async def _load_markets( self, client, reload: bool, market_filter: typing.Optional[typing.Callable[[dict], bool]] = None ): # override for retrier await self._filtered_if_necessary_load_markets(client, reload, market_filter) # sometimes market fees are missing because they are fetched from all tickers # and all ticker can miss symbols on kucoin if client.markets: ccxt_client_util.fix_client_missing_markets_fees(client, reload, _CACHED_CONFIRMED_FEES_BY_SYMBOL) class Kucoin(exchanges.RestExchange): FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True ADAPT_MARKET_STATUS_FOR_CONTRACT_SIZE = True # Set True when get_open_order() can return outdated orders (cancelled or not yet created) CAN_HAVE_DELAYED_OPEN_ORDERS = True # Set True when get_cancelled_order() can return outdated open orders CAN_HAVE_DELAYED_CANCELLED_ORDERS = True DEFAULT_CONNECTOR_CLASS = KucoinConnector # set True when even loading markets can make auth calls when creds are set CAN_MAKE_AUTHENTICATED_REQUESTS_WHEN_LOADING_MARKETS = True FAKE_DDOS_ERROR_INSTANT_RETRY_COUNT = 5 INSTANT_RETRY_ERROR_CODE = "429000" FUTURES_CCXT_CLASS_NAME = "kucoinfutures" MAX_INCREASED_POSITION_QUANTITY_MULTIPLIER = decimal.Decimal("0.95") # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True # set True when fetch_tickers can sometimes miss symbols. In this case, the connector will try to fix it CAN_MISS_TICKERS_IN_ALL_TICKERS = True # set True when get_positions() is not returning empty positions and should use get_position() instead REQUIRES_SYMBOL_FOR_EMPTY_POSITION = True # set False when the exchange refuses to change margin type when an associated position is open SUPPORTS_SET_MARGIN_TYPE_ON_OPEN_POSITIONS = False # get_my_recent_trades only covers the last 24h on kucoin ALLOW_TRADES_FROM_CLOSED_ORDERS = True # set True when get_my_recent_trades should use get_closed_orders # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on futures trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, # supported trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on spot trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } # text content of errors due to api key permissions issues EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'kucoinfutures Access denied, require more permission' ("require more permission",), ] # text content of errors due to account compliancy issues EXCHANGE_COMPLIANCY_ERRORS: typing.List[typing.Iterable[str]] = [ # kucoin {"msg":"Unfortunately, trading is currently unavailable in your location due to country, region, or IP restrictions.","code":"600004"} ("trading is currently unavailable in your location",), ] # text content of errors due to orders not found errors EXCHANGE_ORDER_NOT_FOUND_ERRORS: typing.List[typing.Iterable[str]] = [ # 'kucoin The order does not exist.' ("order does not exist",), ] # text content of errors due to a closed position on the exchange. Relevant for reduce-only orders EXCHANGE_CLOSED_POSITION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'kucoinfutures No open positions to close.' ("no open positions to close", ) ] # text content of errors due to an order that would immediately trigger if created. Relevant for stop losses EXCHANGE_ORDER_IMMEDIATELY_TRIGGER_ERRORS: typing.List[typing.Iterable[str]] = [ # doesn't seem to happen on kucoin ] # text content of errors due to an order that can't be cancelled on exchange (because filled or already cancelled) EXCHANGE_ORDER_UNCANCELLABLE_ERRORS: typing.List[typing.Iterable[str]] = [ ('order cannot be canceled', ), ('order_not_exist_or_not_allow_to_cancel', ) ] # text content of errors due to unhandled IP white list issues EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # "kucoinfutures Invalid request ip, the current clientIp is:e3b:e3b:e3b:e3b:e3b:e3b:e3b:e3b" ("invalid request ip",), ] # set when the exchange can allow users to pay fees in a custom currency (ex: BNB on binance) LOCAL_FEES_CURRENCIES: typing.List[str] = ["KCS"] DEFAULT_BALANCE_CURRENCIES_TO_FETCH = ["USDT"] ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server @classmethod def get_name(cls): return 'kucoin' @classmethod def get_rest_name(cls, exchange_manager): if exchange_manager.is_future: return cls.FUTURES_CCXT_CLASS_NAME return cls.get_name() def get_adapter_class(self): return KucoinCCXTAdapter @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def supports_api_leverage_update(self, symbol: str) -> bool: """ Override if necessary :param symbol: :return: """ if super().supports_api_leverage_update(symbol): # set leverage is only supported on cross positions # https://www.kucoin.com/docs/rest/futures-trading/positions/modify-cross-margin-leverage try: return self.exchange_manager.exchange_personal_data.positions_manager.get_symbol_position_margin_type( symbol ) is trading_enums.MarginType.CROSS except ValueError as err: self.logger.exception(f"Failed to get {symbol} position margin type: {err}") return False async def set_symbol_leverage(self, symbol: str, leverage: float, **kwargs): params = kwargs or {} if self.exchange_manager.is_future: # add marginMode param as required by ccxt self._set_margin_mode_param_if_necessary(symbol, params, lower=True) return await super().set_symbol_leverage(symbol, leverage, **params) def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: # from # https://www.kucoin.com/docs-new/rest/futures-trading/orders/add-order # https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-order # should be 100 to 200 but use 100 to be sure return 100 def supports_native_edit_order(self, order_type: trading_enums.TraderOrderType) -> bool: # return False when default edit_order can't be used and order should always be canceled and recreated instead # only working on HF orders return False async def get_account_id(self, **kwargs: dict) -> str: # It is currently impossible to fetch subaccounts account id, use a constant value to identify it. # updated: 21/05/2024 try: with self.connector.error_describer(): account_id = None subaccount_id = None sub_accounts = await self.connector.client.private_get_sub_accounts() accounts = sub_accounts.get("data", {}).get("items", {}) has_subaccounts = bool(accounts) if has_subaccounts: if len(accounts) == 1: # only 1 account: use its id or name account = accounts[0] # try using subUserId if available # 'ex subUserId: 65d41ea409407d000160cc17 subName: octobot1' account_id = account.get("subUserId") or account["subName"] else: # more than 1 account: consider other accounts for account in accounts: if account["subUserId"]: subaccount_id = account["subName"] else: # only subaccounts have a subUserId: if this condition is True, we are on the main account account_id = account["subName"] if account_id and self.exchange_manager.is_future: account_id = octobot.community.to_community_exchange_internal_name( account_id, commons_constants.CONFIG_EXCHANGE_FUTURE ) if subaccount_id: # there is at least a subaccount: ensure the current account is the main account as there is no way # to know the id of the current account (only a list of existing accounts) subaccount_api_key_details = await self.connector.client.private_get_sub_api_key( {"subName": subaccount_id} ) if "data" not in subaccount_api_key_details or "msg" in subaccount_api_key_details: # subaccounts can't fetch other accounts data, if this is False, we are on a subaccount self.logger.error( f"kucoin api changed: it is now possible to call private_get_sub_accounts on subaccounts. " f"kucoin get_account_id has to be updated. " f"sub_accounts={sub_accounts} subaccount_api_key_details={subaccount_api_key_details}" ) return constants.DEFAULT_ACCOUNT_ID if has_subaccounts and account_id is None: self.logger.error( f"kucoin api changed: can't fetch master account account_id. " f"kucoin get_account_id has to be updated." f"sub_accounts={sub_accounts}" ) account_id = constants.DEFAULT_ACCOUNT_ID # we are on the master account return account_id or constants.DEFAULT_ACCOUNT_ID except ccxt.ExchangeError as err: # ExchangeError('kucoin This user is not a master user') if "not a master user" not in str(err): self.logger.error(f"kucoin api changed: subaccount error on account id is now: '{err}' " f"instead of 'kucoin This user is not a master user'") # raised when calling this endpoint with a subaccount return constants.DEFAULT_SUBACCOUNT_ID def get_market_status(self, symbol, price_example=None, with_fixer=True): """ local override to take "minFunds" into account "minFunds the minimum spot and margin trading amounts" https://docs.kucoin.com/#get-symbols-list """ market_status = super().get_market_status(symbol, price_example=price_example, with_fixer=with_fixer) min_funds = market_status.get(ccxt_constants.CCXT_INFO, {}).get("minFunds") if min_funds is not None: # should only be for spot and margin, use it if available anyway limit_costs = market_status[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS.value][ trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST.value ] # use max (most restrictive) value limit_costs[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST_MIN.value] = max( limit_costs[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS_COST_MIN.value], float(min_funds) ) return market_status @_kucoin_retrier async def get_symbol_prices(self, symbol, time_frame, limit: int = 200, **kwargs: dict): if "since" in kwargs: # prevent ccxt from fillings the end param (not working when trying to get the 1st candle times) kwargs["to"] = int(time.time() * commons_constants.MSECONDS_TO_SECONDS) return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) @_kucoin_retrier async def get_recent_trades(self, symbol, limit=50, **kwargs): # on ccxt kucoin recent trades are received in reverse order from exchange and therefore should never be # filtered by limit before reversing (or most recent trades are lost) recent_trades = await super().get_recent_trades(symbol, limit=None, **kwargs) return recent_trades[::-1][:limit] if recent_trades else [] @_kucoin_retrier async def get_order_book(self, symbol, limit=20, **kwargs): # override default limit to be kucoin complient return await super().get_order_book(symbol, limit=limit, **kwargs) @_kucoin_retrier async def get_price_ticker(self, symbol: str, **kwargs: dict) -> typing.Optional[dict]: return await super().get_price_ticker(symbol, **kwargs) @_kucoin_retrier async def get_all_currencies_price_ticker(self, **kwargs: dict) -> typing.Optional[dict[str, dict]]: return await super().get_all_currencies_price_ticker(**kwargs) def should_log_on_ddos_exception(self, exception) -> bool: """ Override when necessary """ return Kucoin.INSTANT_RETRY_ERROR_CODE not in str(exception) def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: signature_identifier = "KC-API-SIGN" return bool( headers and signature_identifier in headers ) def get_order_additional_params(self, order) -> dict: params = {} if self.exchange_manager.is_future: contract = self.exchange_manager.exchange.get_pair_future_contract(order.symbol) params["leverage"] = float(contract.current_leverage) params["reduceOnly"] = order.reduce_only params["closeOrder"] = order.close_position return params async def _update_balance(self, balance, currency, **kwargs): balance.update(await super().get_balance(code=currency, **kwargs)) @_kucoin_retrier async def get_balance(self, **kwargs: dict): balance = {} if self.exchange_manager.is_future: # on futures, balance has to be fetched per currency # use gather to fetch everything at once (and not allow other requests to get in between) currencies = self.exchange_manager.exchange_config.get_all_traded_currencies() if not currencies: currencies = self.DEFAULT_BALANCE_CURRENCIES_TO_FETCH self.logger.warning( f"Can't fetch balance on {self.exchange_manager.exchange_name} futures when no traded currencies " f"are set, fetching {currencies[0]} balance instead" ) await asyncio.gather(*( self._update_balance(balance, currency, **kwargs) for currency in currencies )) return balance return await super().get_balance(**kwargs) def fetch_stop_order_in_different_request(self, symbol: str) -> bool: # Override in tentacles when stop orders need to be fetched in a separate request from CCXT # Kucoin uses the algo orders endpoint for all stop orders return True @_kucoin_retrier async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: if limit is None: # default is 50, The maximum cannot exceed 1000 # https://www.kucoin.com/docs/rest/futures-trading/orders/get-order-list limit = 200 return await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs) @_kucoin_retrier async def get_order( self, exchange_order_id: str, symbol: typing.Optional[str] = None, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs: dict ) -> dict: return await super().get_order(exchange_order_id, symbol=symbol, order_type=order_type, **kwargs) async def create_order(self, order_type: trading_enums.TraderOrderType, symbol: str, quantity: decimal.Decimal, price: decimal.Decimal = None, stop_price: decimal.Decimal = None, side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, reduce_only: bool = False, params: dict = None) -> typing.Optional[dict]: if self.exchange_manager.is_future: params = params or {} self._set_margin_mode_param_if_necessary(symbol, params) return await super().create_order(order_type, symbol, quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params) async def edit_order(self, exchange_order_id: str, order_type: trading_enums.TraderOrderType, symbol: str, quantity: decimal.Decimal, price: decimal.Decimal, stop_price: decimal.Decimal = None, side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, params: dict = None): if self.exchange_manager.is_future: params = params or {} self._set_margin_mode_param_if_necessary(symbol, params) return await super().edit_order( exchange_order_id, order_type, symbol, quantity, price, stop_price=stop_price, side=side, current_price=current_price, params=params ) def _set_margin_mode_param_if_necessary(self, symbol, params, lower=False): try: # "marginMode": "ISOLATED" // Added field for margin mode: ISOLATED, CROSS, default: ISOLATED # from https://www.kucoin.com/docs/rest/futures-trading/orders/place-order if ( KucoinCCXTAdapter.KUCOIN_MARGIN_MODE not in params and self.exchange_manager.exchange_personal_data.positions_manager.get_symbol_position_margin_type( symbol ) is trading_enums.MarginType.CROSS ): params[KucoinCCXTAdapter.KUCOIN_MARGIN_MODE] = "cross" if lower else "CROSS" except ValueError as err: self.logger.error(f"Impossible to add {KucoinCCXTAdapter.KUCOIN_MARGIN_MODE} to order: {err}") @_kucoin_retrier async def cancel_order( self, exchange_order_id: str, symbol: str, order_type: trading_enums.TraderOrderType, **kwargs: dict ) -> trading_enums.OrderStatus: return await super().cancel_order(exchange_order_id, symbol, order_type, **kwargs) # add retried to _create_order_with_retry to avoid catching error in self._order_operation context manager @_kucoin_retrier async def _create_order_with_retry(self, order_type, symbol, quantity: decimal.Decimal, price: decimal.Decimal, stop_price: decimal.Decimal, side: trading_enums.TradeOrderSide, current_price: decimal.Decimal, reduce_only: bool, params) -> dict: return await super()._create_order_with_retry( order_type=order_type, symbol=symbol, quantity=quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params ) @_kucoin_retrier async def get_my_recent_trades(self, symbol: str = None, since: int = None, limit: int = None, **kwargs: dict) -> list: return await super().get_my_recent_trades(symbol=symbol, since=since, limit=limit, **kwargs) @_kucoin_retrier async def set_symbol_margin_type(self, symbol: str, isolated: bool, **kwargs: dict): """ Set the symbol margin type :param symbol: the symbol :param isolated: when False, margin type is cross, else it's isolated :return: the update result """ try: return await super().set_symbol_margin_type(symbol, isolated, **kwargs) except ccxt.errors.ExchangeError as err: if "Please close or cancel them" in str(err): if self.SUPPORTS_SET_MARGIN_TYPE_ON_OPEN_POSITIONS: raise else: raise trading_errors.NotSupported(f"set_symbol_margin_type is not supported on open positions") raise async def get_position(self, symbol: str, **kwargs: dict) -> dict: """ Get the current user symbol position list :param symbol: the position symbol :return: the user symbol position list """ # todo remove when supported by ccxt async def fetch_position(client, symbol, params={}): market = client.market(symbol) market_id = market['id'] request = { 'symbol': market_id, } response = await client.futuresPrivateGetPosition(request) # # { # "code": "200000", # "data": [ # { # "id": "615ba79f83a3410001cde321", # "symbol": "ETHUSDTM", # "autoDeposit": False, # "maintMarginReq": 0.005, # "riskLimit": 1000000, # "realLeverage": 18.61, # "crossMode": False, # "delevPercentage": 0.86, # "openingTimestamp": 1638563515618, # "currentTimestamp": 1638576872774, # "currentQty": 2, # "currentCost": 83.64200000, # "currentComm": 0.05018520, # "unrealisedCost": 83.64200000, # "realisedGrossCost": 0.00000000, # "realisedCost": 0.05018520, # "isOpen": True, # "markPrice": 4225.01, # "markValue": 84.50020000, # "posCost": 83.64200000, # "posCross": 0.0000000000, # "posInit": 3.63660870, # "posComm": 0.05236717, # "posLoss": 0.00000000, # "posMargin": 3.68897586, # "posMaint": 0.50637594, # "maintMargin": 4.54717586, # "realisedGrossPnl": 0.00000000, # "realisedPnl": -0.05018520, # "unrealisedPnl": 0.85820000, # "unrealisedPnlPcnt": 0.0103, # "unrealisedRoePcnt": 0.2360, # "avgEntryPrice": 4182.10, # "liquidationPrice": 4023.00, # "bankruptPrice": 4000.25, # "settleCurrency": "USDT", # "isInverse": False # } # ] # } # data = client.safe_value(response, 'data') return client.extend(client.parse_position(data, None), params) return self.connector.adapter.adapt_position( await fetch_position(self.connector.client, symbol, **kwargs) ) async def set_symbol_partial_take_profit_stop_loss(self, symbol: str, inverse: bool, tp_sl_mode: trading_enums.TakeProfitStopLossMode): """ take profit / stop loss mode does not exist on kucoin """ class KucoinCCXTAdapter(exchanges.CCXTAdapter): # Funding KUCOIN_DEFAULT_FUNDING_TIME = 8 * commons_constants.HOURS_TO_SECONDS # POSITION KUCOIN_AUTO_DEPOSIT = "autoDeposit" # ORDER KUCOIN_LEVERAGE = "leverage" KUCOIN_MARGIN_MODE = "marginMode" def fix_order(self, raw, symbol=None, **kwargs): fixed = super().fix_order(raw, symbol=symbol, **kwargs) self._ensure_fees(fixed) self._adapt_order_type(fixed) return fixed def fix_trades(self, raw, **kwargs): fixed = super().fix_trades(raw, **kwargs) for trade in fixed: self._adapt_order_type(trade) self._ensure_fees(trade) return fixed def _adapt_order_type(self, fixed): order_info = fixed[trading_enums.ExchangeConstantsOrderColumns.INFO.value] if fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] == "liquid": # liquidation trades: considered as market orders fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = trading_enums.TradeOrderType.MARKET.value if trigger_direction := order_info.get("stop", None): updated_type = trading_enums.TradeOrderType.UNKNOWN.value """ Stop Order Types (https://docs.kucoin.com/futures/#stop-orders) down: Triggers when the price reaches or goes below the stopPrice. up: Triggers when the price reaches or goes above the stopPrice. """ side = fixed.get(trading_enums.ExchangeConstantsOrderColumns.SIDE.value) # SPOT: trigger_direction can be "loss" or "entry" # spot is_stop_loss = False is_stop_entry = False trigger_above = False # spot if trigger_direction == "loss": is_stop_loss = True elif trigger_direction == "entry": is_stop_entry = True # futures elif trigger_direction == "up": trigger_above = True elif trigger_direction == "down": trigger_above = False else: # unhandled, rely on ccxt default parsing self.logger.error( f"Unhandled [{self.connector.exchange_manager.exchange_name}] {trigger_direction} order: skipped custom order type parsing ({fixed})" ) return fixed if is_stop_loss: trigger_above = side == trading_enums.TradeOrderSide.BUY.value if is_stop_entry: self.logger.error( f"Unhandled [{self.connector.exchange_manager.exchange_name}] stop order type " f"{trigger_direction} ({fixed})" ) stop_price = fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.STOP_PRICE.value, None) if side == trading_enums.TradeOrderSide.BUY.value: if trigger_above: updated_type = trading_enums.TradeOrderType.STOP_LOSS.value else: # take profits are not yet handled as such: consider them as limit orders updated_type = trading_enums.TradeOrderType.LIMIT.value # waiting for TP handling if not fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]: fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = stop_price # waiting for TP handling else: # selling if trigger_above: # take profits are not yet handled as such: consider them as limit orders updated_type = trading_enums.TradeOrderType.LIMIT.value # waiting for TP handling if not fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]: fixed[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] = stop_price # waiting for TP handling else: updated_type = trading_enums.TradeOrderType.STOP_LOSS.value # stop loss are not tagged as such by ccxt, force it fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = updated_type fixed[trading_enums.ExchangeConstantsOrderColumns.TRIGGER_ABOVE.value] = trigger_above return fixed def parse_funding_rate(self, fixed, from_ticker=False, **kwargs): """ Kucoin next funding time is not provided To obtain the last_funding_time : => timestamp(previous_funding_timestamp) + timestamp(KUCOIN_DEFAULT_FUNDING_TIME) """ if from_ticker: # no funding info in ticker return {} funding_dict = super().parse_funding_rate(fixed, from_ticker=from_ticker, **kwargs) previous_funding_timestamp = fixed[trading_enums.ExchangeConstantsFundingColumns.NEXT_FUNDING_TIME.value] fixed.update({ # patch LAST_FUNDING_TIME in tentacle trading_enums.ExchangeConstantsFundingColumns.LAST_FUNDING_TIME.value: previous_funding_timestamp, # patch NEXT_FUNDING_TIME in tentacle trading_enums.ExchangeConstantsFundingColumns.NEXT_FUNDING_TIME.value: previous_funding_timestamp + self.KUCOIN_DEFAULT_FUNDING_TIME, }) return funding_dict def parse_position(self, fixed, **kwargs): raw_position_info = fixed[ccxt_enums.ExchangePositionCCXTColumns.INFO.value] parsed = super().parse_position(fixed, **kwargs) parsed[trading_enums.ExchangeConstantsPositionColumns.AUTO_DEPOSIT_MARGIN.value] = ( raw_position_info.get(self.KUCOIN_AUTO_DEPOSIT, False) # unset for cross positions ) parsed_leverage = self.safe_decimal( parsed, trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value, constants.ZERO ) if parsed_leverage == constants.ZERO: # on kucoin, fetched empty position don't have a leverage value. Since it's required within OctoBot, # add it manually symbol = parsed[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] if self.connector.exchange_manager.exchange.has_pair_future_contract(symbol): parsed[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value] = \ self.connector.exchange_manager.exchange.get_pair_future_contract(symbol).current_leverage else: parsed[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value] = \ constants.DEFAULT_SYMBOL_LEVERAGE return parsed ================================================ FILE: Trading/Exchange/kucoin/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Kucoin"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/kucoin_websocket_feed/__init__.py ================================================ from .kucoin_websocket import KucoinCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/kucoin_websocket_feed/kucoin_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.kucoin.kucoin_exchange as kucoin_exchange class KucoinCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } FUTURES_EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: Feeds.UNSUPPORTED.value, # not supported in futures Feeds.TICKER: True, Feeds.CANDLE: Feeds.UNSUPPORTED.value, # not supported in futures } SPOT_EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } IGNORED_FEED_PAIRS = { # When ticker or future index is available : no need to calculate mark price from recent trades # On kucoin, ticker feed is not containing close price: recent trades are required # Feeds.TRADES: [Feeds.TICKER, Feeds.FUTURES_INDEX], Feeds.TRADES: [Feeds.FUTURES_INDEX], # When candles are available : use min timeframe kline to push ticker Feeds.TICKER: [Feeds.KLINE] } # Feeds to create above which not to use websockets # Kucoin raises "exceed max permits per second" when subscribing to more than 100 feeds MAX_HANDLED_FEEDS = 100 RECREATE_CLIENT_ON_DISCONNECT = True # when True, a new ccxt websocket client will replace the previous # one when the exchange is disconnected @classmethod def get_name(cls): return kucoin_exchange.Kucoin.get_name() def get_feed_name(self): if self.exchange_manager.is_future: return kucoin_exchange.Kucoin.FUTURES_CCXT_CLASS_NAME return super().get_feed_name() @classmethod def update_exchange_feeds(cls, exchange_manager): if exchange_manager.is_future: cls.EXCHANGE_FEEDS = cls.FUTURES_EXCHANGE_FEEDS else: cls.EXCHANGE_FEEDS = cls.SPOT_EXCHANGE_FEEDS def get_adapter_class(self, adapter_class): return kucoin_exchange.KucoinCCXTAdapter ================================================ FILE: Trading/Exchange/kucoin_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["KucoinCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/kucoin_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/kucoin_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...kucoin_websocket_feed import KucoinCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() async with websocket_test_tools.ws_exchange_manager(config, KucoinCryptofeedWebsocketConnector.get_name()) \ as exchange_manager_instance: await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=KucoinCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/BTC", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value }, time_before_assert=20 ) ================================================ FILE: Trading/Exchange/lbank/__init__.py ================================================ from .lbank_exchange import LBank ================================================ FILE: Trading/Exchange/lbank/lbank_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import hashlib import ccxt import octobot_trading.exchanges as exchanges import octobot_trading.constants as constants import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants class LBankSignConnectorMixin: def __init__(self): # used by default to force signed requests self._force_signed_requests: typing.Optional[bool] = None def _lazy_maybe_force_signed_requests(self, origin_ccxt_sign): def lazy_sign(path, api, method, params, headers, body): if self._force_signed_requests is None: # force sign if the exchange requires authentication or if the connector is authenticated self._force_signed_requests = self.exchange_manager.exchange.requires_authentication( self.exchange_manager.exchange.tentacle_config, None, None ) or ( self.exchange_manager.exchange.connector and self.exchange_manager.exchange.connector.is_authenticated ) if self._force_signed_requests: self.logger.info(f"Enabled force signing requests for {self.exchange_manager.exchange_name}") ccxt_sign_result = origin_ccxt_sign(path, api, method, params, headers, body) if self._force_signed_requests: if self.exchange_manager.exchange.is_authenticated_request( ccxt_sign_result.get("url"), ccxt_sign_result.get("method"), ccxt_sign_result.get("headers"), ccxt_sign_result.get("body") ): # already signed return ccxt_sign_result # force signature return self._force_sign(path, api, method, params, headers, body) return ccxt_sign_result return lazy_sign def _force_sign(self, path, api, method, params, headers, body): self = self.client # to use the same code as ccxt.async_support.lbank.sign (same self) # same code as ccxt.async_support.lbank.sign but forced to sign query = self.omit(params, self.extract_params(path)) url = self.urls['api']['rest'] + '/' + self.version + '/' + self.implode_params(path, params) # Every spot endpoint ends with ".do" if api[0] == 'spot': url += '.do' else: url = self.urls['api']['contract'] + '/' + self.implode_params(path, params) # local override # if api[1] == 'public': # if query: # url += '?' + self.urlencode(self.keysort(query)) # else: # end local override self.check_required_credentials() timestamp = str(self.milliseconds()) echostr = self.uuid22() + self.uuid16() query = self.extend({ 'api_key': self.apiKey, }, query) signatureMethod = None if len(self.secret) > 32: signatureMethod = 'RSA' else: signatureMethod = 'HmacSHA256' auth = self.rawencode(self.keysort(self.extend({ 'echostr': echostr, 'signature_method': signatureMethod, 'timestamp': timestamp, }, query))) encoded = self.encode(auth) hash = self.hash(encoded, 'md5') uppercaseHash = hash.upper() sign = None if signatureMethod == 'RSA': cacheSecretAsPem = self.safe_bool(self.options, 'cacheSecretAsPem', True) pem = None if cacheSecretAsPem: pem = self.safe_value(self.options, 'pem') if pem is None: pem = self.convert_secret_to_pem(self.encode(self.secret)) self.options['pem'] = pem else: pem = self.convert_secret_to_pem(self.encode(self.secret)) sign = self.rsa(uppercaseHash, pem, 'sha256') elif signatureMethod == 'HmacSHA256': sign = self.hmac(self.encode(uppercaseHash), self.encode(self.secret), hashlib.sha256) query['sign'] = sign # local override all_params = self.urlencode(self.keysort(query)) if api[1] == 'public': if query: url += '?' + all_params else: body = all_params # end local override headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'timestamp': timestamp, 'signature_method': signatureMethod, 'echostr': echostr, } return {'url': url, 'method': method, 'body': body, 'headers': headers} class LBankConnector(exchanges.CCXTConnector, LBankSignConnectorMixin): def __init__(self, *args, **kwargs): exchanges.CCXTConnector.__init__(self, *args, **kwargs) LBankSignConnectorMixin.__init__(self) # used by default to force signed requests self._force_signed_requests: typing.Optional[bool] = None def _create_client(self, force_unauth=False): exchanges.CCXTConnector._create_client(self, force_unauth=force_unauth) self.register_client_mocks() def register_client_mocks(self): self.client.sign = self._lazy_maybe_force_signed_requests(self.client.sign) self.client.parse_order = self.parse_order_mock(self.client) def parse_order_mock(self, client): origin_parse_order = client.parse_order def _mocked_parse_order(order, market=None): try: return origin_parse_order(order, market) except AttributeError as err: if "'NoneType' object has no attribute 'split'" in str(err): # no order fetched raise ccxt.OrderNotFound(f"Order not found") # should not happen raise return _mocked_parse_order class LBank(exchanges.RestExchange): DEFAULT_CONNECTOR_CLASS = LBankConnector FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True SUPPORT_FETCHING_CANCELLED_ORDERS = False ENABLE_SPOT_BUY_MARKET_WITH_COST = True REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees # should be fetched using recent trades. @classmethod def get_name(cls): return 'lbank' def get_adapter_class(self): return LBankCCXTAdapter async def get_account_id(self, **kwargs: dict) -> str: # not supported return constants.DEFAULT_ACCOUNT_ID def get_additional_connector_config(self): # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders return { ccxt_constants.CCXT_OPTIONS: { "createMarketBuyOrderRequiresPrice": False # disable quote conversion } } def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: body_signature_identifiers = "sign=" header_signature_method_identifiers = "signature_method" return bool( headers and header_signature_method_identifiers in headers ) or bool( body and body_signature_identifiers in body ) class LBankCCXTAdapter(exchanges.CCXTAdapter): pass ================================================ FILE: Trading/Exchange/lbank/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["LBank"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/lbank/resources/lbank.md ================================================ LBank is a basic RestExchange adaptation for LBank exchange. ================================================ FILE: Trading/Exchange/lbank_websocket_feed/__init__.py ================================================ from .lbank_websocket import LBankCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/lbank_websocket_feed/lbank_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.time_frame_manager as time_frame_manager import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.lbank.lbank_exchange as lbank_exchange class LBankCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector, lbank_exchange.LBankSignConnectorMixin): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } FIX_CANDLES_TIMEZONE_IF_NEEDED: bool = True def __init__(self, *args, **kwargs): exchanges.CCXTWebsocketConnector.__init__(self, *args, **kwargs) lbank_exchange.LBankSignConnectorMixin.__init__(self) def _create_client(self): exchanges.CCXTWebsocketConnector._create_client(self) self.client.sign = self._lazy_maybe_force_signed_requests(self.client.sign) def _should_authenticate(self): return exchanges.CCXTWebsocketConnector._should_authenticate(self) or ( # oveerride to authenticate if the connector is authenticated self.exchange_manager.exchange.connector and self.exchange_manager.exchange.connector.is_authenticated ) @classmethod def get_name(cls): return lbank_exchange.LBank.get_name() def get_adapter_class(self, adapter_class): return lbank_exchange.LBankCCXTAdapter ================================================ FILE: Trading/Exchange/lbank_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["LBankCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/mexc/__init__.py ================================================ from .mexc_exchange import MEXC ================================================ FILE: Trading/Exchange/mexc/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["MEXC"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/mexc/mexc_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import contextlib import decimal import time import typing import ccxt import hashlib from ccxt.base.types import Entry import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.enums as trading_enums import octobot_trading.errors import octobot_commons.symbols as symbols_util import octobot_commons.constants as commons_constants import octobot_commons import octobot_trading.constants as constants class MEXCConnector(exchanges.CCXTConnector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._force_signed_requests: typing.Optional[bool] = None def _create_client(self, force_unauth=False): super()._create_client(force_unauth=force_unauth) self.client.sign = self._lazy_maybe_force_signed_requests(self.client.sign) def _lazy_maybe_force_signed_requests(self, origin_ccxt_sign): def lazy_sign(path, api, method, params, headers, body): if self._force_signed_requests is None: self._force_signed_requests = self.exchange_manager.exchange.requires_authentication( self.exchange_manager.exchange.tentacle_config, None, None ) if self._force_signed_requests: self.logger.info(f"Enabled force signing requests for {self.exchange_manager.exchange_name}") ccxt_sign_result = origin_ccxt_sign(path, api, method, params, headers, body) if self._force_signed_requests: url = ccxt_sign_result.get("url") or "" ccxt_headers = ccxt_sign_result.get("headers") or {} if "signature=" in url or "Signature" in ccxt_headers: # already signed return ccxt_sign_result # force signature return self._force_sign(path, api, method, params, headers, body) return ccxt_sign_result return lazy_sign def _force_sign(self, path, api, method, params, headers, body): self = self.client # to use the same code as ccxt.async_support.mexc.sign (same self) # same code as ccxt.async_support.mexc.sign but forced to sign section = self.safe_string(api, 0) access = self.safe_string(api, 1) path, params = self.resolve_path(path, params) url = None if section == 'spot' or section == 'broker': if section == 'broker': url = self.urls['api'][section][access] + '/' + path else: url = self.urls['api'][section][access] + '/api/' + self.version + '/' + path urlParams = params if True or access == 'private': # local override to force signature if section == 'broker' and ((method == 'POST') or (method == 'PUT') or (method == 'DELETE')): urlParams = { 'timestamp': self.nonce(), 'recvWindow': self.safe_integer(self.options, 'recvWindow', 5000), } body = self.json(params) else: urlParams['timestamp'] = self.nonce() urlParams['recvWindow'] = self.safe_integer(self.options, 'recvWindow', 5000) paramsEncoded = '' if urlParams: paramsEncoded = self.urlencode(urlParams) url += '?' + paramsEncoded if True or access == 'private': # local override to force signature self.check_required_credentials() signature = self.hmac(self.encode(paramsEncoded), self.encode(self.secret), hashlib.sha256) url += '&' + 'signature=' + signature headers = { 'X-MEXC-APIKEY': self.apiKey, 'source': self.safe_string(self.options, 'broker', 'CCXT'), } if (method == 'POST') or (method == 'PUT') or (method == 'DELETE'): headers['Content-Type'] = 'application/json' elif section == 'contract' or section == 'spot2': url = self.urls['api'][section][access] + '/' + self.implode_params(path, params) params = self.omit(params, self.extract_params(path)) if False and access == 'public': # local override to force signature if params: url += '?' + self.urlencode(params) else: self.check_required_credentials() timestamp = str(self.nonce()) auth = '' headers = { 'ApiKey': self.apiKey, 'Request-Time': timestamp, 'Content-Type': 'application/json', 'source': self.safe_string(self.options, 'broker', 'CCXT'), } if method == 'POST': auth = self.json(params) body = auth else: params = self.keysort(params) if params: auth += self.urlencode(params) url += '?' + auth auth = self.apiKey + timestamp + auth signature = self.hmac(self.encode(auth), self.encode(self.secret), hashlib.sha256) headers['Signature'] = signature return {'url': url, 'method': method, 'body': body, 'headers': headers} class MEXC(exchanges.RestExchange): DEFAULT_CONNECTOR_CLASS = MEXCConnector FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True # set True when disabled symbols should still be considered (ex: mexc with its temporary api trading disabled symbols) # => avoid skipping untradable symbols INCLUDE_DISABLED_SYMBOLS_IN_AVAILABLE_SYMBOLS = True EXPECT_POSSIBLE_ORDER_NOT_FOUND_DURING_ORDER_CREATION = True # set True when get_order() can return None # (order not found) when orders are instantly filled on exchange and are not fully processed on the exchange side. REQUIRE_ORDER_FEES_FROM_TRADES = True # set True when get_order is not giving fees on closed orders and fees # text content of errors due to unhandled authentication issues # set True when create_market_buy_order_with_cost should be used to create buy market orders # (useful to predict the exact spent amount) ENABLE_SPOT_BUY_MARKET_WITH_COST = True EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'mexc {"code":700007,"msg":"No permission to access the endpoint."}' ("no permission to access",), ] EXCHANGE_AUTHENTICATION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'mexc {"code":10072,"msg":"Api key info invalid"}' ("api key info invalid",), ] EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # "mexc {"code":700006,"msg":"IP [33.33.33.33] not in the ip white list"}" ("not in the ip white list",), ] # set when the exchange can allow users to pay fees in a custom currency (ex: BNB on binance) LOCAL_FEES_CURRENCIES: typing.List[str] = ["MX"] ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server @classmethod def get_name(cls): return 'mexc' def get_adapter_class(self): return MEXCCCXTAdapter def get_additional_connector_config(self): # tell ccxt to use amount as provided and not to compute it by multiplying it by price which is done here # (price should not be sent to market orders). Only used for buy market orders return { ccxt_constants.CCXT_OPTIONS: { "createMarketBuyOrderRequiresPrice": False, # disable quote conversion "recvWindow": 60000, # default is 5000, avoid time related issues } } async def get_account_id(self, **kwargs: dict) -> str: # https://www.mexc.com/api-docs/spot-v3/spot-account-trade#query-uid private_get_uid = Entry('uid', ['spot', 'private'], 'GET', {'cost': 10}) try: resp = await private_get_uid.unbound_method(self.connector.client) return str(resp["uid"]) except Exception as err: self.logger.exception( err, True, f"Unexpected error when getting {self.get_name()} account ID: {err}. Using default account ID." ) return constants.DEFAULT_ACCOUNT_ID def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: # unknown (05/06/2025) return super().get_max_orders_count(symbol, order_type) def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: url_signature_identifiers = "signature=" header_signature_identifiers = "Signature" return bool( headers and header_signature_identifiers in headers ) or bool( url and url_signature_identifiers in url ) async def get_all_tradable_symbols(self, active_only=True) -> set[str]: """ Override if the exchange is not allowing trading for all available symbols (ex: MEXC) :return: the list of all symbols supported by the exchange that can currently be traded through API """ if CACHED_MEXC_API_HANDLED_SYMBOLS.should_be_updated(): await CACHED_MEXC_API_HANDLED_SYMBOLS.update(self) return CACHED_MEXC_API_HANDLED_SYMBOLS.symbols async def _create_specific_order(self, order_type, symbol, quantity: decimal.Decimal, price: decimal.Decimal = None, side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, stop_price: decimal.Decimal = None, reduce_only: bool = False, params=None) -> dict: async with self._mexc_handled_symbols_filter(symbol): return await super()._create_specific_order(order_type, symbol, quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params) @contextlib.asynccontextmanager async def _mexc_handled_symbols_filter(self, symbol): try: yield except (ccxt.BadSymbol, ccxt.BadRequest) as err: if "symbol not support api" in str(err): raise octobot_trading.errors.UntradableSymbolError( f"{self.get_name()} error: {symbol} trading pair is not available to the API at the moment, " f"{symbol} is under maintenance ({err})." ) raise err async def get_open_orders(self, symbol: str = None, since: int = None, limit: int = None, **kwargs: dict) -> list: return self._filter_orders( await super().get_open_orders(symbol=symbol, since=since, limit=limit, **kwargs), True ) async def get_closed_orders(self, symbol: str = None, since: int = None, limit: int = None, **kwargs: dict) -> list: return self._filter_orders( await super().get_closed_orders(symbol=symbol, since=since, limit=limit, **kwargs), False ) async def get_order( self, exchange_order_id: str, symbol: typing.Optional[str] = None, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs: dict ) -> dict: try: return await super().get_order( exchange_order_id, symbol=symbol, order_type=order_type, **kwargs ) except octobot_trading.errors.FailedRequest as err: if "Order does not exist" in str(err): return None raise def _filter_orders(self, orders: list, open_only: bool) -> list: return [ order for order in orders if ( open_only and order[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == trading_enums.OrderStatus.OPEN.value ) or ( not open_only and order[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] != trading_enums.OrderStatus.OPEN.value ) ] class APIHandledSymbols: """ MEXC has pairs that are sometimes tradable from the exchange UI but not from the API. Get the list of currently api tradable symbols from the defaultSymbols endpoint. """ def __init__(self, update_interval): self.symbols = set() self.last_update = 0 self._update_interval = update_interval def should_be_updated(self): return time.time() - self._update_interval >= self._update_interval async def update(self, exchange): try: result = await exchange.connector.client.spot2_public_get_market_api_default_symbols() self.symbols = set( # in some cases, "_" is not replaced as symbol is not found in markets exchange.connector.client.safe_market(s)["symbol"].replace("_", octobot_commons.MARKET_SEPARATOR) for s in result["data"]["symbol"] ) self.last_update = time.time() exchange.logger.info(f"Updated handled symbols, list: {self.symbols}") except Exception as err: exchange.logger.exception(err, True, f"Error when fetching api-tradable symbols: {err}") # make it available a singleton CACHED_MEXC_API_HANDLED_SYMBOLS = APIHandledSymbols(commons_constants.DAYS_TO_SECONDS) class MEXCCCXTAdapter(exchanges.CCXTAdapter): def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) try: if fixed[ trading_enums.ExchangeConstantsOrderColumns.STATUS.value ] == trading_enums.OrderStatus.CANCELED.value \ and fixed[trading_enums.ExchangeConstantsOrderColumns.FEE.value] is None: symbol = fixed.get(trading_enums.ExchangeConstantsOrderColumns.SYMBOL.value, "") fixed[trading_enums.ExchangeConstantsOrderColumns.FEE.value] = { trading_enums.FeePropertyColumns.CURRENCY.value: symbols_util.parse_symbol(symbol).quote if symbol else "", trading_enums.FeePropertyColumns.COST.value: 0.0, trading_enums.FeePropertyColumns.IS_FROM_EXCHANGE.value: False, trading_enums.FeePropertyColumns.EXCHANGE_ORIGINAL_COST.value: 0.0, } except KeyError as err: self.logger.debug(f"Failed to fix order fees: {err}") return fixed ================================================ FILE: Trading/Exchange/mexc_websocket_feed/__init__.py ================================================ from .mexc_websocket import MEXCCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/mexc_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["MEXCCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/mexc_websocket_feed/mexc_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.mexc.mexc_exchange as mexc_exchange class MEXCCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return mexc_exchange.MEXC.get_name() def get_adapter_class(self, adapter_class): return mexc_exchange.MEXCCCXTAdapter ================================================ FILE: Trading/Exchange/myokx/__init__.py ================================================ from .myokx_exchange import MyOkx ================================================ FILE: Trading/Exchange/myokx/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["MyOkx"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/myokx/myokx_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as trading_enums import tentacles.Trading.Exchange.okx.okx_exchange as okx_exchange class MyOkx(okx_exchange.Okx): @classmethod def get_name(cls): return 'myokx' @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, ] ================================================ FILE: Trading/Exchange/myokx/resources/myokx.md ================================================ Okx is a basic RestExchange adaptation for MyOKX exchange. ================================================ FILE: Trading/Exchange/myokx_websocket_feed/__init__.py ================================================ from .myokx_websocket import MyOKXCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/myokx_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["MyOKXCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/myokx_websocket_feed/myokx_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.myokx.myokx_exchange as myokx_exchange import tentacles.Trading.Exchange.okx_websocket_feed as okx_websocket_feed class MyOKXCCXTWebsocketConnector(okx_websocket_feed.OKXCCXTWebsocketConnector): @classmethod def get_name(cls): return myokx_exchange.MyOkx.get_name() ================================================ FILE: Trading/Exchange/ndax/__init__.py ================================================ from .ndax_exchange import Ndax ================================================ FILE: Trading/Exchange/ndax/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Ndax"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/ndax/ndax_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums class Ndax(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True DEFAULT_MAX_LIMIT = 500 @classmethod def get_name(cls): return 'ndax' async def get_symbol_prices(self, symbol, time_frame, limit: int = None, **kwargs: dict): # ohlcv without limit is not supported, replaced by a default max limit if limit is None: limit = self.DEFAULT_MAX_LIMIT return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) ================================================ FILE: Trading/Exchange/ndax/resources/ndax.md ================================================ Ndax is a basic RestExchange adaptation for Ndax exchange. ================================================ FILE: Trading/Exchange/ndax/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/okcoin/__init__.py ================================================ from .okcoin_exchange import Okcoin ================================================ FILE: Trading/Exchange/okcoin/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Okcoin"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/okcoin/okcoin_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.okx as okx_tentacle class Okcoin(okx_tentacle.Okx): @classmethod def get_name(cls): return 'okcoin' ================================================ FILE: Trading/Exchange/okcoin/resources/okcoin.md ================================================ Okcoin is a basic RestExchange adaptation for Okcoin exchange. ================================================ FILE: Trading/Exchange/okcoin/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/okx/__init__.py ================================================ from .okx_exchange import Okx ================================================ FILE: Trading/Exchange/okx/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Okx"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/okx/okx_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import typing import octobot_commons.constants as commons_constants import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges import octobot_trading.constants as constants import octobot_trading.errors as trading_errors import octobot_trading.exchanges.connectors.ccxt.enums as ccxt_enums import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants import octobot_trading.exchanges.connectors.ccxt.ccxt_connector as ccxt_connector import octobot_trading.personal_data as trading_personal_data # def _disabled_okx_algo_order_creation(f): # async def disabled_okx_algo_order_creation_wrapper(*args, **kwargs): # # Algo order prevent bundled orders from working as they require to use the regular order api # # Since the regular order api works for limit and market orders as well, us it all the time # # Algo api is used for stop losses. # # This ccxt issue will remain as long as privatePostTradeOrderAlgo will be used for each order with a # # stopLossPrice or takeProfitPrice even when both are set (which make it an invalid okx algo order) # connector = args[0] # client = connector.client # client.privatePostTradeOrderAlgo = client.privatePostTradeOrder # try: # return await f(*args, **kwargs) # finally: # client.privatePostTradeOrderAlgo = connector.get_saved_data(connector.PRIVATE_POST_TRADE_ORDER_ALGO) # return disabled_okx_algo_order_creation_wrapper # # # def _enabled_okx_algo_order_creation(f): # async def enabled_okx_algo_order_creation_wrapper(*args, **kwargs): # # Used to force algo orders availability and avoid concurrency issues due to _disabled_algo_order_creation # connector = args[0] # connector.client.privatePostTradeOrderAlgo = connector.get_saved_data(connector.PRIVATE_POST_TRADE_ORDER_ALGO) # return await f(*args, **kwargs) # return enabled_okx_algo_order_creation_wrapper # # # class OkxConnector(ccxt_connector.CCXTConnector): # PRIVATE_POST_TRADE_ORDER_ALGO = "privatePostTradeOrderAlgo" # # def _create_client(self, force_unauth=False): # super()._create_client(force_unauth=force_unauth) # # save client.privatePostTradeOrderAlgo ref to prevent concurrent _disabled_algo_order_creation issues # self.set_saved_data(self.PRIVATE_POST_TRADE_ORDER_ALGO, self.client.privatePostTradeOrderAlgo) # # @_disabled_okx_algo_order_creation # async def create_market_buy_order(self, symbol, quantity, price=None, params=None) -> dict: # return await super().create_market_buy_order(symbol, quantity, price=price, params=params) # # @_disabled_okx_algo_order_creation # async def create_limit_buy_order(self, symbol, quantity, price=None, params=None) -> dict: # return await super().create_limit_buy_order(symbol, quantity, price=price, params=params) # # @_disabled_okx_algo_order_creation # async def create_market_sell_order(self, symbol, quantity, price=None, params=None) -> dict: # return await super().create_market_sell_order(symbol, quantity, price=price, params=params) # # @_disabled_okx_algo_order_creation # async def create_limit_sell_order(self, symbol, quantity, price=None, params=None) -> dict: # return await super().create_limit_sell_order(symbol, quantity, price=price, params=params) # # @_enabled_okx_algo_order_creation # async def create_market_stop_loss_order(self, symbol, quantity, price, side, current_price, params=None) -> dict: # return self.adapter.adapt_order( # await self.client.create_order( # symbol, trading_enums.TradeOrderType.MARKET.value, side, quantity, params=params # ), # symbol=symbol, quantity=quantity # ) class Okx(exchanges.RestExchange): DESCRIPTION = "" # set True when even loading markets can make auth calls when creds are set CAN_MAKE_AUTHENTICATED_REQUESTS_WHEN_LOADING_MARKETS = True # text content of errors due to orders not found errors EXCHANGE_PERMISSION_ERRORS: typing.List[typing.Iterable[str]] = [ # OKX ex: okx {"msg":"API key doesn't exist","code":"50119"} ("api", "key", "doesn't exist"), ] # text content of errors due to account compliancy issues EXCHANGE_COMPLIANCY_ERRORS: typing.List[typing.Iterable[str]] = [ # OKX ex: Trading of this pair or contract is restricted due to local compliance requirements ("restricted", "compliance"), # OKX ex: You can't trade this pair or borrow this crypto due to local compliance restrictions. ("restrictions", "compliance"), ] # text content of errors due to unhandled authentication issues EXCHANGE_AUTHENTICATION_ERRORS: typing.List[typing.Iterable[str]] = [ # 'okx {"msg":"API key doesn't exist","code":"50119"}' ("api key doesn't exist",), ] # text content of errors due to unhandled IP white list issues EXCHANGE_IP_WHITELIST_ERRORS: typing.List[typing.Iterable[str]] = [ # okx {"msg":"Your IP 1.1.1.1 is not included in your API key's xxxx IP whitelist.","code":"50110"} ("is not included in your", "ip whitelist"), ] FIX_MARKET_STATUS = True ADAPT_MARKET_STATUS_FOR_CONTRACT_SIZE = True # DEFAULT_CONNECTOR_CLASS = OkxConnector # disabled until futures support is back MAX_PAGINATION_LIMIT: int = 100 # value from https://www.okex.com/docs/en/#spot-orders_pending # set when the exchange returns nothing when fetching historical candles with a too early start time # (will iterate historical OHLCV requests over this window) MAX_FETCHED_OHLCV_COUNT = 100 # Okx default take profits are market orders # note: use BUY_MARKET and SELL_MARKET since in reality those are conditional market orders, which behave the same # way as limit order but with higher fees _OKX_BUNDLED_ORDERS = [trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.BUY_MARKET, trading_enums.TraderOrderType.SELL_MARKET] # should be overridden locally to match exchange support SUPPORTED_ELEMENTS = { trading_enums.ExchangeTypes.FUTURE.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ # trading_enums.TraderOrderType.STOP_LOSS, # supported on futures trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: { trading_enums.TraderOrderType.BUY_MARKET: _OKX_BUNDLED_ORDERS, trading_enums.TraderOrderType.SELL_MARKET: _OKX_BUNDLED_ORDERS, trading_enums.TraderOrderType.BUY_LIMIT: _OKX_BUNDLED_ORDERS, trading_enums.TraderOrderType.SELL_LIMIT: _OKX_BUNDLED_ORDERS, }, }, trading_enums.ExchangeTypes.SPOT.value: { # order that should be self-managed by OctoBot trading_enums.ExchangeSupportedElements.UNSUPPORTED_ORDERS.value: [ trading_enums.TraderOrderType.STOP_LOSS, trading_enums.TraderOrderType.STOP_LOSS_LIMIT, trading_enums.TraderOrderType.TAKE_PROFIT, trading_enums.TraderOrderType.TAKE_PROFIT_LIMIT, trading_enums.TraderOrderType.TRAILING_STOP, trading_enums.TraderOrderType.TRAILING_STOP_LIMIT ], # order that can be bundled together to create them all in one request trading_enums.ExchangeSupportedElements.SUPPORTED_BUNDLED_ORDERS.value: {}, } } # Set True when exchange is not returning empty position details when fetching a position with a specified symbol # Exchange will then fallback to self.get_mocked_empty_position when having get_position returning None REQUIRES_MOCKED_EMPTY_POSITION = True # https://www.okx.com/learn/complete-guide-to-okex-api-v5-upgrade#h-rest-2 # set True when get_positions() is not returning empty positions and should use get_position() instead REQUIRES_SYMBOL_FOR_EMPTY_POSITION = True ADJUST_FOR_TIME_DIFFERENCE = True # set True when the client needs to adjust its requests for time difference with the server @classmethod def get_name(cls): return 'okx' def get_adapter_class(self): return OKXCCXTAdapter @classmethod def is_supporting_sandbox(cls) -> bool: return False @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def is_authenticated_request(self, url: str, method: str, headers: dict, body) -> bool: signature_identifier = "OK-ACCESS-SIGN" return bool( headers and signature_identifier in headers ) def _fix_limit(self, limit: int) -> int: return min(self.MAX_PAGINATION_LIMIT, limit) if limit else limit async def get_account_id(self, **kwargs: dict) -> str: accounts = await self.connector.client.fetch_accounts() try: with self.connector.error_describer(): return accounts[0]["id"] except IndexError as err: # should never happen as at least one account should be available raise def get_max_orders_count(self, symbol: str, order_type: trading_enums.TraderOrderType) -> int: # unknown (05/06/2025) return super().get_max_orders_count(symbol, order_type) async def get_sub_account_list(self): sub_account_list = (await self.connector.client.privateGetUsersSubaccountList()).get("data", []) if not sub_account_list: return [] return [ { trading_enums.SubAccountColumns.ID.value: sub_account.get("subAcct", ""), trading_enums.SubAccountColumns.NAME.value: sub_account.get("label", "") } for sub_account in sub_account_list if sub_account.get("enable", False) ] def get_order_additional_params(self, order) -> dict: params = {} if self.exchange_manager.is_future: params["reduceOnly"] = order.reduce_only params[ccxt_enums.ExchangeOrderCCXTColumns.MARGIN_MODE.value] = self._get_ccxt_margin_type(order.symbol) return params def get_bundled_order_parameters(self, order, stop_loss_price=None, take_profit_price=None) -> dict: """ Returns the updated params when this exchange supports orders created upon other orders fill (ex: a stop loss created at the same time as a buy order) :param order: the initial order :param stop_loss_price: the bundled order stopLoss price :param take_profit_price: the bundled order takeProfit price :return: A dict with the necessary parameters to create the bundled order on exchange alongside the base order in one request """ params = {} if not ( trading_personal_data.is_stop_order(order.order_type) or trading_personal_data.is_take_profit_order(order.order_type) ): # force non algo order "order type" if isinstance(order, trading_personal_data.MarketOrder): params["ordType"] = "market" elif isinstance(order, trading_personal_data.LimitOrder): params["px"] = str(order.origin_price) params["ordType"] = "limit" if stop_loss_price is not None: params[self.connector.adapter.OKX_STOP_LOSS_PRICE] = float(stop_loss_price) params["slOrdPx"] = -1 # execute as market order if take_profit_price is not None: params[self.connector.adapter.OKX_TAKE_PROFIT_PRICE] = float(take_profit_price) params["tpOrdPx"] = -1 # execute as market order return params async def _get_all_typed_orders(self, method, symbol=None, since=None, limit=None, **kwargs) -> list: # todo replace by settings fetch_stop_order_in_different_request method when OKX will be stable again limit = self._fix_limit(limit) is_stop_order = kwargs.get("stop", False) if is_stop_order and self.connector.adapter.OKX_ORDER_TYPE not in kwargs: kwargs[self.connector.adapter.OKX_ORDER_TYPE] = self.connector.adapter.OKX_CONDITIONAL_ORDER_TYPE regular_orders = await method(symbol=symbol, since=since, limit=limit, **kwargs) if is_stop_order: # only require stop orders return regular_orders # add order types of order (different param in api endpoint) other_orders = [] if self.exchange_manager.is_future: # stop orders are futures only for now for order_type in self._get_used_order_types(): kwargs["ordType"] = order_type other_orders += await method(symbol=symbol, since=since, limit=limit, **kwargs) return regular_orders + other_orders async def get_open_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await self._get_all_typed_orders( super().get_open_orders, symbol=symbol, since=since, limit=limit, **kwargs ) async def get_closed_orders(self, symbol=None, since=None, limit=None, **kwargs) -> list: return await self._get_all_typed_orders( super().get_closed_orders, symbol=symbol, since=since, limit=limit, **kwargs ) async def get_order( self, exchange_order_id: str, symbol: typing.Optional[str] = None, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs: dict ) -> dict: try: order = await super().get_order( exchange_order_id, symbol=symbol, order_type=order_type, **kwargs ) return order except trading_errors.NotSupported: if kwargs.get("stop", False): # from ccxt 2.8.4 # fetchOrder() does not support stop orders, use fetchOpenOrders() fetchCanceledOrders() or fetchClosedOrders return await self.get_order_from_open_and_closed_orders(exchange_order_id, symbol=symbol, **kwargs) raise def order_request_kwargs_factory( self, exchange_order_id: str, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs ) -> dict: params = kwargs or {} try: if "stop" not in params: order_type = ( order_type or self.exchange_manager.exchange_personal_data.orders_manager.get_order( None, exchange_order_id=exchange_order_id ).order_type ) params["stop"] = ( trading_personal_data.is_stop_order(order_type) or trading_personal_data.is_take_profit_order(order_type) ) except KeyError as err: self.logger.warning( f"Order {exchange_order_id} not found in order manager: considering it a regular (no stop/take profit) order {err}" ) return params def _is_oco_order(self, params): return all( oco_order_param in (params or {}) for oco_order_param in ( self.connector.adapter.OKX_STOP_LOSS_PRICE, self.connector.adapter.OKX_TAKE_PROFIT_PRICE ) ) async def create_order(self, order_type: trading_enums.TraderOrderType, symbol: str, quantity: decimal.Decimal, price: decimal.Decimal = None, stop_price: decimal.Decimal = None, side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, reduce_only: bool = False, params: dict = None) -> typing.Optional[dict]: if self._is_oco_order(params): raise trading_errors.NotSupported( f"OCO bundled orders (orders including both a stop loss and take profit price) " f"are not yet supported on {self.get_name()}" ) return await super().create_order(order_type, symbol, quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params) def _get_ccxt_margin_type(self, symbol, contract=None): if not self.exchange_manager.exchange.has_pair_future_contract(symbol): raise KeyError(f"{symbol} contract unavailable") contract = contract or self.exchange_manager.exchange.get_pair_future_contract(symbol) return ccxt_enums.ExchangeMarginTypes.ISOLATED.value if contract.is_isolated() \ else ccxt_enums.ExchangeMarginTypes.CROSS.value def _get_margin_query_params(self, symbol, **kwargs): pos_side = self.connector.adapter.OKX_ONE_WAY_MODE if not self.exchange_manager.exchange.has_pair_future_contract(symbol): raise KeyError(f"{symbol} contract unavailable") else: contract = self.exchange_manager.exchange.get_pair_future_contract(symbol) if not contract.is_one_way_position_mode(): self.logger.debug(f"Switching {symbol} position mode to one way") contract.set_position_mode(is_one_way=True, is_hedge=False) # todo: handle other position sides when cross is supported kwargs = kwargs or {} kwargs.update({ self.connector.adapter.OKX_LEVER: float(contract.current_leverage), self.connector.adapter.OKX_MARGIN_MODE: self._get_ccxt_margin_type(symbol, contract=contract), self.connector.adapter.OKX_POS_SIDE: pos_side, }) return kwargs async def get_symbol_leverage(self, symbol: str, **kwargs: dict): """ :param symbol: the symbol :return: the current symbol leverage multiplier """ kwargs = kwargs or {} if ccxt_enums.ExchangePositionCCXTColumns.MARGIN_MODE.value not in kwargs: margin_type = ccxt_enums.ExchangeMarginTypes.ISOLATED.value try: margin_type = self._get_ccxt_margin_type(symbol) except KeyError: pass kwargs[ccxt_enums.ExchangePositionCCXTColumns.MARGIN_MODE.value] = margin_type return await self.connector.get_symbol_leverage(symbol=symbol, **kwargs) async def set_symbol_leverage(self, symbol: str, leverage: float, **kwargs): """ Set the symbol leverage :param symbol: the symbol :param leverage: the leverage :return: the update result """ kwargs = self._get_margin_query_params(symbol, **kwargs) kwargs.pop(self.connector.adapter.OKX_LEVER, None) return await self.connector.set_symbol_leverage(leverage=leverage, symbol=symbol, **kwargs) async def set_symbol_margin_type(self, symbol: str, isolated: bool, **kwargs: dict): kwargs = self._get_margin_query_params(symbol, **kwargs) kwargs.pop(self.connector.adapter.OKX_MARGIN_MODE) await super().set_symbol_margin_type(symbol, isolated, **kwargs) async def get_position(self, symbol: str, **kwargs: dict) -> dict: """ Get the current user symbol position :param symbol: the position symbol :return: the user symbol position """ position = await super().get_position(symbol=symbol, **kwargs) if position[trading_enums.ExchangeConstantsPositionColumns.SIZE.value] == constants.ZERO: await self._update_position_with_leverage_data(symbol, position) if position[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] != symbol: # happened in previous ccxt version, todo remove if no seen again raise ValueError( f"Invalid position symbol: " f"{position[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value]}, " f"expected {symbol}" ) return position async def _update_position_with_leverage_data(self, symbol, position): leverage_data = await self.get_symbol_leverage(symbol) adapter = self.connector.adapter OKX_info = leverage_data[ccxt_constants.CCXT_INFO] position[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] = \ adapter.parse_position_mode(OKX_info[0][adapter.OKX_POS_SIDE]) position[trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value] = \ adapter.parse_margin_type(leverage_data[ccxt_enums.ExchangeLeverageCCXTColumns.MARGIN_MODE.value]) position[trading_enums.ExchangeConstantsPositionColumns.LEVERAGE.value] = \ leverage_data[trading_enums.ExchangeConstantsLeveragePropertyColumns.LEVERAGE.value] async def set_symbol_partial_take_profit_stop_loss(self, symbol: str, inverse: bool, tp_sl_mode: trading_enums.TakeProfitStopLossMode): """ take profit / stop loss mode does not exist on okx futures """ def _get_used_order_types(self): return [ # stop orders self.connector.adapter.OKX_CONDITIONAL_ORDER_TYPE, # created with bundled orders including stop loss & take profit: unsupported for now # self.connector.adapter.OKX_OCO_ORDER_TYPE, ] class OKXCCXTAdapter(exchanges.CCXTAdapter): # ORDERS OKX_ORDER_TYPE = "ordType" OKX_TRIGGER_ORDER_TYPE = "trigger" OKX_OCO_ORDER_TYPE = "oco" OKX_CONDITIONAL_ORDER_TYPE = "conditional" OKX_BASIC_ORDER_TYPES = ["market", "limit"] OKX_LAST_PRICE = "last" OKX_STOP_LOSS_PRICE = "stopLossPrice" OKX_TAKE_PROFIT_PRICE = "takeProfitPrice" OKX_STOP_LOSS_TRIGGER_PRICE = "slTriggerPx" OKX_TAKE_PROFIT_TRIGGER_PRICE = "tpTriggerPx" # POSITIONS OKX_MARGIN_MODE = "mgnMode" OKX_POS_SIDE = "posSide" OKX_ONE_WAY_MODE = "net" # LEVERAGE OKX_LEVER = "lever" DATA = "data" # Funding OKX_DEFAULT_FUNDING_TIME = 8 * commons_constants.HOURS_TO_SECONDS def fix_order(self, raw, symbol=None, **kwargs): fixed = super().fix_order(raw, symbol=symbol, **kwargs) self._adapt_order_type(fixed) return fixed def _adapt_order_type(self, fixed): order_info = fixed[trading_enums.ExchangeConstantsOrderColumns.INFO.value] if fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.TYPE.value, None) not in self.OKX_BASIC_ORDER_TYPES: trigger_price = fixed.get(ccxt_enums.ExchangeOrderCCXTColumns.TRIGGER_PRICE.value, None) last_price = order_info.get(self.OKX_LAST_PRICE, None) stop_loss_trigger_price = order_info.get(self.OKX_STOP_LOSS_TRIGGER_PRICE, None) take_profit_trigger_price = order_info.get(self.OKX_TAKE_PROFIT_TRIGGER_PRICE, None) updated_type = trading_enums.TradeOrderType.UNKNOWN.value if stop_loss_trigger_price and take_profit_trigger_price: # OCO order, unsupported yet self.logger.debug(f"Unsupported OKX OCO (stop loss & take profit in a single order): {fixed}") updated_type = trading_enums.TradeOrderType.UNSUPPORTED.value elif stop_loss_trigger_price is not None: updated_type = trading_enums.TradeOrderType.STOP_LOSS.value elif take_profit_trigger_price is not None: updated_type = trading_enums.TradeOrderType.TAKE_PROFIT.value elif last_price is not None: last_price = float(last_price) side = fixed[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] if side == trading_enums.TradeOrderSide.BUY.value: # trigger stop loss buy when price goes bellow stop_price, untriggered when last price is above if last_price > trigger_price: updated_type = trading_enums.TradeOrderType.STOP_LOSS.value else: updated_type = trading_enums.TradeOrderType.TAKE_PROFIT.value else: # trigger take profit sell when price goes above stop_price, untriggered when last price is bellow if last_price < trigger_price: updated_type = trading_enums.TradeOrderType.TAKE_PROFIT.value else: updated_type = trading_enums.TradeOrderType.STOP_LOSS.value else: self.logger.error( f"Unknown [{self.connector.exchange_manager.exchange_name}] order type, order: {fixed}" ) # stop loss and take profits are not tagged as such by ccxt, force it fixed[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] = updated_type return fixed def parse_position(self, fixed, force_empty=False, **kwargs): parsed = super().parse_position(fixed, force_empty=force_empty, **kwargs) # use isolated by default. Set in set_leverage parsed[trading_enums.ExchangeConstantsPositionColumns.MARGIN_TYPE.value] = \ trading_enums.MarginType( fixed.get(ccxt_enums.ExchangePositionCCXTColumns.MARGIN_MODE.value) or trading_enums.MarginType.ISOLATED.value ) # use one way by default. Set in set_leverage if parsed[trading_enums.ExchangeConstantsPositionColumns.SIZE.value] == constants.ZERO: parsed[trading_enums.ExchangeConstantsPositionColumns.POSITION_MODE.value] = \ trading_enums.PositionMode.ONE_WAY return parsed def parse_margin_type(self, margin_mode): if margin_mode == ccxt_enums.ExchangeMarginTypes.ISOLATED.value: return trading_enums.MarginType.ISOLATED elif margin_mode == ccxt_enums.ExchangeMarginTypes.CROSS.value: return trading_enums.MarginType.CROSS raise ValueError(margin_mode) def parse_position_mode(self, position_mode): if position_mode == self.OKX_ONE_WAY_MODE: return trading_enums.PositionMode.ONE_WAY return trading_enums.PositionMode.HEDGE def parse_leverage(self, fixed, **kwargs): fixed = super().parse_leverage(fixed, **kwargs) leverages = [ fixed[ccxt_enums.ExchangeLeverageCCXTColumns.LONG_LEVERAGE.value], fixed[ccxt_enums.ExchangeLeverageCCXTColumns.SHORT_LEVERAGE.value], ] fixed[trading_enums.ExchangeConstantsLeveragePropertyColumns.LEVERAGE.value] = \ decimal.Decimal(str(leverages[0] or leverages[1])) return fixed def parse_funding_rate(self, fixed, from_ticker=False, **kwargs): if from_ticker: # no funding info in ticker return {} fixed = super().parse_funding_rate(fixed, from_ticker=from_ticker, **kwargs) next_funding_timestamp = fixed[trading_enums.ExchangeConstantsFundingColumns.NEXT_FUNDING_TIME.value] fixed.update({ # patch LAST_FUNDING_TIME in tentacle trading_enums.ExchangeConstantsFundingColumns.LAST_FUNDING_TIME.value: max(next_funding_timestamp - self.OKX_DEFAULT_FUNDING_TIME, 0) }) return fixed ================================================ FILE: Trading/Exchange/okx/resources/okx.md ================================================ Okx is a basic RestExchange adaptation for OKX exchange. ================================================ FILE: Trading/Exchange/okx/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/okx_websocket_feed/__init__.py ================================================ from .okx_websocket import OKXCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/okx_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["OKXCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/okx_websocket_feed/okx_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.okx.okx_exchange as okx_exchange class OKXCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return okx_exchange.Okx.get_name() ================================================ FILE: Trading/Exchange/okx_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/okx_websocket_feed/tests/test_unauthenticated_mocked_feeds.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.channels_name as channels_name import octobot_commons.enums as commons_enums import octobot_commons.tests as commons_tests import octobot_trading.exchanges as exchanges import octobot_trading.util.test_tools.websocket_test_tools as websocket_test_tools from ...okx_websocket_feed import OKXCryptofeedWebsocketConnector # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_start_spot_websocket(): config = commons_tests.load_test_config() async with websocket_test_tools.ws_exchange_manager(config, OKXCryptofeedWebsocketConnector.get_name()) \ as exchange_manager_instance: await websocket_test_tools.test_unauthenticated_push_to_channel_coverage_websocket( websocket_exchange_class=exchanges.CryptofeedWebSocketExchange, websocket_connector_class=OKXCryptofeedWebsocketConnector, exchange_manager=exchange_manager_instance, config=config, symbols=["BTC/USDT", "ETH/USDT"], time_frames=[commons_enums.TimeFrames.ONE_HOUR], expected_pushed_channels={ channels_name.OctoBotTradingChannelsName.MARK_PRICE_CHANNEL.value, }, time_before_assert=20 ) ================================================ FILE: Trading/Exchange/okxus/__init__.py ================================================ from .okxus_exchange import OkxUs ================================================ FILE: Trading/Exchange/okxus/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["OkxUs"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/okxus/okxus_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as trading_enums import tentacles.Trading.Exchange.okx.okx_exchange as okx_exchange class OkxUs(okx_exchange.Okx): @classmethod def get_name(cls): return 'okxus' @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, ] ================================================ FILE: Trading/Exchange/okxus/resources/okxus.md ================================================ OkxUs is a basic RestExchange adaptation for OKX US exchange. ================================================ FILE: Trading/Exchange/okxus_websocket_feed/__init__.py ================================================ from .okxus_websocket import OkxUsCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/okxus_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["OkxUsCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/okxus_websocket_feed/okxus_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import tentacles.Trading.Exchange.okxus.okxus_exchange as okxus_exchange import tentacles.Trading.Exchange.okx_websocket_feed as okx_websocket_feed class OkxUsCCXTWebsocketConnector(okx_websocket_feed.OKXCCXTWebsocketConnector): @classmethod def get_name(cls): return okxus_exchange.OkxUs.get_name() ================================================ FILE: Trading/Exchange/phemex/__init__.py ================================================ from .phemex_exchange import Phemex ================================================ FILE: Trading/Exchange/phemex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Phemex"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/phemex/phemex_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import decimal import typing import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums class Phemex(exchanges.RestExchange): DESCRIPTION = "" ALLOWED_OHLCV_LIMITS = [5, 10, 50, 100, 500, 1000] FIX_MARKET_STATUS = True @classmethod def get_name(cls): return 'phemex' def get_adapter_class(self): return PhemexCCXTAdapter def _get_adapted_limit(self, limit): prev = self.ALLOWED_OHLCV_LIMITS[0] for adapted in self.ALLOWED_OHLCV_LIMITS: if adapted > limit: return prev prev = adapted return prev async def get_symbol_prices(self, symbol, time_frame, limit: int = 500, **kwargs: dict): if limit not in self.ALLOWED_OHLCV_LIMITS: limit = self._get_adapted_limit(limit) return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) async def get_kline_price(self, symbol: str, time_frame: commons_enums.TimeFrames, **kwargs: dict) -> typing.Optional[list]: return (await self.get_symbol_prices(symbol, time_frame, limit=5))[-1:] async def create_order(self, order_type: trading_enums.TraderOrderType, symbol: str, quantity: decimal.Decimal, price: decimal.Decimal = None, stop_price: decimal.Decimal = None, side: trading_enums.TradeOrderSide = None, current_price: decimal.Decimal = None, reduce_only: bool = False, params: dict = None) -> typing.Optional[dict]: if order_type is trading_enums.TraderOrderType.BUY_MARKET \ or order_type is trading_enums.TraderOrderType.SELL_MARKET: # remove price argument on market orders or ccxt will try to convert cost into amount and # make rounding differences price = None return await super().create_order(order_type, symbol, quantity, price=price, stop_price=stop_price, side=side, current_price=current_price, reduce_only=reduce_only, params=params) def _get_ohlcv_params(self, time_frame, limit, **kwargs): if limit is None: return {} to_time = self.connector.client.milliseconds() time_frame_msec = commons_enums.TimeFramesMinutes[time_frame] * commons_constants.MSECONDS_TO_MINUTE kwargs.update({ "from": to_time - (time_frame_msec * (limit + 1)), "limit": limit, }) return kwargs async def cancel_order( self, exchange_order_id: str, symbol: str, order_type: trading_enums.TraderOrderType, **kwargs: dict ) -> trading_enums.OrderStatus: order_status = await super().cancel_order(exchange_order_id, symbol, order_type, **kwargs) if order_status == trading_enums.OrderStatus.PENDING_CANCEL: # cancelled orders can't be fetched, consider as cancelled order_status = trading_enums.OrderStatus.CANCELED return order_status async def get_order( self, exchange_order_id: str, symbol: typing.Optional[str] = None, order_type: typing.Optional[trading_enums.TraderOrderType] = None, **kwargs: dict ) -> dict: if order := await self.connector.get_order( symbol=symbol, exchange_order_id=exchange_order_id, order_type=order_type, **kwargs ): return order # try from closed orders (get_order is not returning filled or cancelled orders) if order := await self.get_order_from_open_and_closed_orders(exchange_order_id, symbol): return order # try from trades (get_order is not returning filled or cancelled orders) return await self._get_order_from_trades(symbol, exchange_order_id, {}) async def _get_order_from_trades(self, symbol, exchange_order_id, order_to_update): # usually the last trade is the right one for _ in range(3): if (order := await self.get_order_from_trades(symbol, exchange_order_id, order_to_update)) is None: await asyncio.sleep(3) else: return order return None class PhemexCCXTAdapter(exchanges.CCXTAdapter): PHEMEX_FEE_CURRENCY = "feeCurrency" def fix_order(self, raw, **kwargs): fixed = super().fix_order(raw, **kwargs) try: if fixed[ trading_enums.ExchangeConstantsOrderColumns.STATUS.value ] == trading_enums.OrderStatus.CLOSED.value \ and fixed[trading_enums.ExchangeConstantsOrderColumns.FEE.value][ trading_enums.FeePropertyColumns.CURRENCY.value] is None: order_info = fixed[trading_enums.ExchangeConstantsOrderColumns.INFO.value] fixed[trading_enums.ExchangeConstantsOrderColumns.FEE.value][ trading_enums.FeePropertyColumns.CURRENCY.value] = order_info[self.PHEMEX_FEE_CURRENCY] except KeyError as err: self.logger.debug(f"Failed to fix order fees: {err}") try: if fixed[ trading_enums.ExchangeConstantsOrderColumns.STATUS.value ] == trading_enums.OrderStatus.CLOSED.value: base_amount = fixed[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] fixed[trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value] = \ base_amount - fixed[trading_enums.ExchangeConstantsOrderColumns.REMAINING.value] except KeyError as err: self.logger.debug(f"Failed to fix order amount: {err}") return fixed ================================================ FILE: Trading/Exchange/phemex/resources/phemex.md ================================================ Phemex is a basic RestExchange adaptation for Phemex exchange. ================================================ FILE: Trading/Exchange/phemex/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/phemex_websocket_feed/__init__.py ================================================ from .phemex_websocket import PhemexCCXTWebsocketConnector ================================================ FILE: Trading/Exchange/phemex_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["PhemexCCXTWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/phemex_websocket_feed/phemex_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.phemex.phemex_exchange as phemex_exchange class PhemexCCXTWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: True, Feeds.TICKER: True, Feeds.CANDLE: True, } @classmethod def get_name(cls): return phemex_exchange.Phemex.get_name() ================================================ FILE: Trading/Exchange/phemex_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/poloniex/__init__.py ================================================ from .poloniex_exchange import Poloniex ================================================ FILE: Trading/Exchange/poloniex/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Poloniex"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/poloniex/poloniex_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class Poloniex(exchanges.RestExchange): FIX_MARKET_STATUS = True REMOVE_MARKET_STATUS_PRICE_LIMITS = True @classmethod def get_name(cls): return 'poloniex' ================================================ FILE: Trading/Exchange/poloniex/resources/poloniex.md ================================================ Poloniex is a basic RestExchange adaptation for Poloniex exchange. ================================================ FILE: Trading/Exchange/poloniex/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/polymarket/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .ccxt import CCXTPolymarketExchange, CCXTAsyncPolymarketExchange, CCXTProPolymarketExchange from .polymarket_exchange import Polymarket ================================================ FILE: Trading/Exchange/polymarket/ccxt/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .polymarket_sync import polymarket as CCXTPolymarketExchange from .polymarket_async import polymarket as CCXTAsyncPolymarketExchange from .polymarket_pro import polymarket as CCXTProPolymarketExchange import ccxt ccxt.__all__.append("polymarket") ccxt.exchanges.append("polymarket") ccxt.polymarket = CCXTPolymarketExchange import ccxt.async_support ccxt.async_support.__all__.append("polymarket") ccxt.async_support.exchanges.append("polymarket") ccxt.async_support.polymarket = CCXTAsyncPolymarketExchange import ccxt.pro ccxt.pro.exchanges.append("polymarket") ccxt.pro.polymarket = CCXTProPolymarketExchange ================================================ FILE: Trading/Exchange/polymarket/ccxt/polymarket_abstract.py ================================================ from ccxt.base.types import Entry class ImplicitAPI: gamma_public_get_markets = gammaPublicGetMarkets = Entry('markets', ['gamma', 'public'], 'GET', {'cost': 1.6}) gamma_public_get_markets_id = gammaPublicGetMarketsId = Entry('markets/{id}', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_markets_id_tags = gammaPublicGetMarketsIdTags = Entry('markets/{id}/tags', ['gamma', 'public'], 'GET', {'cost': 2}) gamma_public_get_markets_slug_slug = gammaPublicGetMarketsSlugSlug = Entry('markets/slug/{slug}', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_events = gammaPublicGetEvents = Entry('events', ['gamma', 'public'], 'GET', {'cost': 2}) gamma_public_get_events_id = gammaPublicGetEventsId = Entry('events/{id}', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_series = gammaPublicGetSeries = Entry('series', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_series_id = gammaPublicGetSeriesId = Entry('series/{id}', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_search = gammaPublicGetSearch = Entry('search', ['gamma', 'public'], 'GET', {'cost': 0.667}) gamma_public_get_comments = gammaPublicGetComments = Entry('comments', ['gamma', 'public'], 'GET', {'cost': 2}) gamma_public_get_comments_id = gammaPublicGetCommentsId = Entry('comments/{id}', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_sports = gammaPublicGetSports = Entry('sports', ['gamma', 'public'], 'GET', {'cost': 0.267}) gamma_public_get_sports_id = gammaPublicGetSportsId = Entry('sports/{id}', ['gamma', 'public'], 'GET', {'cost': 0.267}) data_public_get_positions = dataPublicGetPositions = Entry('positions', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_trades = dataPublicGetTrades = Entry('trades', ['data', 'public'], 'GET', {'cost': 2.67}) data_public_get_activity = dataPublicGetActivity = Entry('activity', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_holders = dataPublicGetHolders = Entry('holders', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_value = dataPublicGetValue = Entry('value', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_closed_positions = dataPublicGetClosedPositions = Entry('closed-positions', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_traded = dataPublicGetTraded = Entry('traded', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_oi = dataPublicGetOi = Entry('oi', ['data', 'public'], 'GET', {'cost': 1}) data_public_get_live_volume = dataPublicGetLiveVolume = Entry('live-volume', ['data', 'public'], 'GET', {'cost': 1}) bridge_public_get_supported_assets = bridgePublicGetSupportedAssets = Entry('supported-assets', ['bridge', 'public'], 'GET', {'cost': 1}) bridge_public_post_deposit = bridgePublicPostDeposit = Entry('deposit', ['bridge', 'public'], 'POST', {'cost': 1}) clob_public_get_orderbook = clobPublicGetOrderbook = Entry('orderbook', ['clob', 'public'], 'GET', {'cost': 1}) clob_public_get_orderbook_token_id = clobPublicGetOrderbookTokenId = Entry('orderbook/{token_id}', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_market_condition_id_trades = clobPublicGetMarketConditionIdTrades = Entry('market/{condition_id}/trades', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_trades = clobPublicGetTrades = Entry('trades', ['clob', 'public'], 'GET', {'cost': 0.667}) clob_public_get_prices_history = clobPublicGetPricesHistory = Entry('prices-history', ['clob', 'public'], 'GET', {'cost': 2}) clob_public_get_price = clobPublicGetPrice = Entry('price', ['clob', 'public'], 'GET', {'cost': 1}) clob_public_get_prices = clobPublicGetPrices = Entry('prices', ['clob', 'public'], 'GET', {'cost': 2.5}) clob_public_get_midpoint = clobPublicGetMidpoint = Entry('midpoint', ['clob', 'public'], 'GET', {'cost': 1}) clob_public_get_midpoints = clobPublicGetMidpoints = Entry('midpoints', ['clob', 'public'], 'GET', {'cost': 2.5}) clob_public_get_spread = clobPublicGetSpread = Entry('spread', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_last_trade_price = clobPublicGetLastTradePrice = Entry('last-trade-price', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_last_trades_prices = clobPublicGetLastTradesPrices = Entry('last-trades-prices', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get = clobPublicGet = Entry('', ['clob', 'public'], 'GET', {'cost': 4}) clob_public_get_time = clobPublicGetTime = Entry('time', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_tick_size = clobPublicGetTickSize = Entry('tick-size', ['clob', 'public'], 'GET', {'cost': 4}) clob_public_get_neg_risk = clobPublicGetNegRisk = Entry('neg-risk', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_fee_rate = clobPublicGetFeeRate = Entry('fee-rate', ['clob', 'public'], 'GET', {'cost': 0.04}) clob_public_get_markets = clobPublicGetMarkets = Entry('markets', ['clob', 'public'], 'GET', {'cost': 2}) clob_public_post_books = clobPublicPostBooks = Entry('books', ['clob', 'public'], 'POST', {'cost': 2.5}) clob_public_post_spreads = clobPublicPostSpreads = Entry('spreads', ['clob', 'public'], 'POST', {'cost': 0.04}) clob_public_post_prices = clobPublicPostPrices = Entry('prices', ['clob', 'public'], 'POST', {'cost': 2.5}) clob_private_get_order = clobPrivateGetOrder = Entry('order', ['clob', 'private'], 'GET', {'cost': 0.667}) clob_private_get_orders = clobPrivateGetOrders = Entry('orders', ['clob', 'private'], 'GET', {'cost': 1.33}) clob_private_get_trades = clobPrivateGetTrades = Entry('trades', ['clob', 'private'], 'GET', {'cost': 0.667}) clob_private_get_builder_trades = clobPrivateGetBuilderTrades = Entry('builder-trades', ['clob', 'private'], 'GET', {'cost': 0.667}) clob_private_get_notifications = clobPrivateGetNotifications = Entry('notifications', ['clob', 'private'], 'GET', {'cost': 1.6}) clob_private_get_balance_allowance = clobPrivateGetBalanceAllowance = Entry('balance-allowance', ['clob', 'private'], 'GET', {'cost': 1.6}) clob_private_get_order_scoring = clobPrivateGetOrderScoring = Entry('order-scoring', ['clob', 'private'], 'GET', {'cost': 0.04}) clob_private_get_auth_derive_api_key = clobPrivateGetAuthDeriveApiKey = Entry('auth/derive-api-key', ['clob', 'private'], 'GET', {'cost': 4}) clob_private_post_order = clobPrivatePostOrder = Entry('order', ['clob', 'private'], 'POST', {'cost': 0.5}) clob_private_post_orders = clobPrivatePostOrders = Entry('orders', ['clob', 'private'], 'POST', {'cost': 1}) clob_private_post_orders_scoring = clobPrivatePostOrdersScoring = Entry('orders-scoring', ['clob', 'private'], 'POST', {'cost': 0.04}) clob_private_post_auth_api_key = clobPrivatePostAuthApiKey = Entry('auth/api-key', ['clob', 'private'], 'POST', {'cost': 4}) clob_private_delete_order = clobPrivateDeleteOrder = Entry('order', ['clob', 'private'], 'DELETE', {'cost': 0.5}) clob_private_delete_orders = clobPrivateDeleteOrders = Entry('orders', ['clob', 'private'], 'DELETE', {'cost': 1}) clob_private_delete_cancel_all = clobPrivateDeleteCancelAll = Entry('cancel-all', ['clob', 'private'], 'DELETE', {'cost': 4}) clob_private_delete_cancel_market_orders = clobPrivateDeleteCancelMarketOrders = Entry('cancel-market-orders', ['clob', 'private'], 'DELETE', {'cost': 1}) clob_private_delete_notifications = clobPrivateDeleteNotifications = Entry('notifications', ['clob', 'private'], 'DELETE', {'cost': 0.04}) clob_private_put_balance_allowance = clobPrivatePutBalanceAllowance = Entry('balance-allowance', ['clob', 'private'], 'PUT', {'cost': 10}) ================================================ FILE: Trading/Exchange/polymarket/ccxt/polymarket_async.py ================================================ # -*- coding: utf-8 -*- # PLEASE DO NOT EDIT THIS FILE, IT IS GENERATED AND WILL BE OVERWRITTEN: # https://github.com/ccxt/ccxt/blob/master/CONTRIBUTING.md#how-to-contribute-code from ccxt.async_support.base.exchange import Exchange from .polymarket_abstract import ImplicitAPI import hashlib import math import json import numbers from ccxt.base.types import Any, Int, Market, MarketType, Num, Order, OrderBook, OrderRequest, OrderSide, OrderType, Str, Strings, Ticker, Tickers, OrderBooks, Trade, TradingFeeInterface from typing import List from ccxt.base.errors import ExchangeError from ccxt.base.errors import AuthenticationError from ccxt.base.errors import PermissionDenied from ccxt.base.errors import ArgumentsRequired from ccxt.base.errors import BadRequest from ccxt.base.errors import InsufficientFunds from ccxt.base.errors import InvalidOrder from ccxt.base.errors import OrderNotFound from ccxt.base.errors import NetworkError from ccxt.base.errors import RateLimitExceeded from ccxt.base.errors import ExchangeNotAvailable from ccxt.base.errors import OnMaintenance from ccxt.base.decimal_to_precision import ROUND from ccxt.base.decimal_to_precision import TICK_SIZE from ccxt.base.precise import Precise class polymarket(Exchange, ImplicitAPI): def describe(self) -> Any: return self.deep_extend(super(polymarket, self).describe(), { 'id': 'polymarket', 'name': 'Polymarket', 'countries': ['US'], 'version': '1', # Rate limits are enforced using Cloudflare's throttling system # Requests over the limit are throttled/delayed rather than rejected # See https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation formula: cost = (1000 / rateLimit) * 60 / requests_per_minute # With rateLimit = 50ms(20 req/s = 1200 req/min), base cost = 1.0 # General limits: # - General Rate Limiting: 5000 requests / 10s(500 req/s = 30000 req/min) => cost = 0.04 # - CLOB(General): 5000 requests / 10s(500 req/s = 30000 req/min) => cost = 0.04 # - GAMMA(General): 750 requests / 10s(75 req/s = 4500 req/min) => cost = 0.267 # - Data API(General): 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # Setting to 50ms(20 req/s) to match the most restrictive general limit(Data API) # Specific endpoint costs are calculated relative to self base rateLimit 'rateLimit': 50, # 20 requests per second(matches Data API general limit) 'certified': False, 'pro': True, 'requiredCredentials': { 'apiKey': False, 'secret': False, 'walletAddress': True, 'privateKey': True, }, 'has': { 'CORS': None, 'spot': False, 'margin': False, 'swap': False, 'future': False, 'option': True, 'addMargin': False, 'cancelOrder': True, 'cancelOrders': True, 'createDepositAddress': True, # TODO with https://docs.polymarket.com/developers/misc-endpoints/bridge-deposit 'createMarketBuyOrderWithCost': False, 'createMarketOrder': True, 'createMarketOrderWithCost': False, 'createMarketSellOrderWithCost': False, 'createOrder': True, 'createOrders': True, 'createStopLimitOrder': False, 'createStopMarketOrder': False, 'createStopOrder': False, 'editOrder': False, 'fetchBalance': True, 'fetchBorrowInterest': False, 'fetchBorrowRateHistories': False, 'fetchBorrowRateHistory': False, 'fetchClosedOrders': False, 'fetchCrossBorrowRate': False, 'fetchCrossBorrowRates': False, 'fetchCurrencies': False, 'fetchDepositAddress': False, 'fetchDepositAddresses': True, # TODO with https://docs.polymarket.com/developers/misc-endpoints/bridge-supported-assets 'fetchDepositAddressesByNetwork': True, # TODO with https://docs.polymarket.com/developers/misc-endpoints/bridge-supported-assets 'fetchDeposits': False, 'fetchFundingHistory': False, 'fetchFundingRate': False, 'fetchFundingRateHistory': False, 'fetchFundingRates': False, 'fetchIndexOHLCV': False, 'fetchIsolatedBorrowRate': False, 'fetchIsolatedBorrowRates': False, 'fetchLedger': False, 'fetchLedgerEntry': False, 'fetchLeverageTiers': False, 'fetchMarkets': True, 'fetchMarkOHLCV': False, 'fetchMyTrades': True, 'fetchOHLCV': True, 'fetchOpenInterest': True, 'fetchOpenInterestHistory': False, 'fetchOpenOrders': True, 'fetchOrder': True, 'fetchOrderBook': True, 'fetchOrderBooks': True, 'fetchOrders': True, 'fetchPositionMode': False, 'fetchPremiumIndexOHLCV': False, 'fetchStatus': True, 'fetchTicker': True, 'fetchTickers': True, 'fetchTime': True, 'fetchTrades': True, 'fetchTradingFee': True, 'fetchTradingFees': False, 'fetchWithdrawals': False, 'setLeverage': False, 'setMarginMode': False, 'transfer': False, 'withdraw': False, }, 'urls': { 'logo': 'https://polymarket.com/favicon.ico', 'api': { 'gamma': 'https://gamma-api.polymarket.com', 'clob': 'https://clob.polymarket.com', # Can be overridden with options.clobHost 'data': 'https://data-api.polymarket.com', 'bridge': 'https://bridge.polymarket.com', 'ws': 'wss://ws-subscriptions-clob.polymarket.com/ws/', # CLOB WebSocket for subscriptions 'rtds': 'wss://ws-live-data.polymarket.com', # Real Time Data Socket for crypto prices and comments }, 'test': {}, # TODO if exists 'www': 'https://polymarket.com', 'doc': [ 'https://docs.polymarket.com', ], 'fees': 'https://docs.polymarket.com/developers/CLOB/introduction', }, 'api': { # GAMMA API: https://gamma-api.polymarket.com # Rate limits: https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation: cost = (1000 / 50) * 60 / requests_per_minute = 1200 / requests_per_minute # - GAMMA(General): 750 requests / 10s(75 req/s = 4500 req/min) => cost = 0.267 # - GAMMA Get Comments: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA /events: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA /markets: 125 requests / 10s(12.5 req/s = 750 req/min) => cost = 1.6 # - GAMMA /markets /events listing: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA Tags: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA Search: 300 requests / 10s(30 req/s = 1800 req/min) => cost = 0.667 'gamma': { 'public': { 'get': { # Market endpoints 'markets': 1.6, # GET /markets - used by fetchMarkets(125 req/10s = 750 req/min) 'markets/{id}': 0.267, # GET /markets/{id} - used by gammaPublicGetMarketsId(general limit) 'markets/{id}/tags': 2.0, # GET /markets/{id}/tags - used by gammaPublicGetMarketsIdTags(100 req/10s = 600 req/min) 'markets/slug/{slug}': 0.267, # GET /markets/slug/{slug} - used by gammaPublicGetMarketsSlugSlug(general limit) # Event endpoints 'events': 2.0, # GET /events - used by gammaPublicGetEvents(100 req/10s = 600 req/min) 'events/{id}': 0.267, # GET /events/{id} - used by gammaPublicGetEventsId(general limit) # Series endpoints 'series': 0.267, # GET /series - used by gammaPublicGetSeries(general limit) 'series/{id}': 0.267, # GET /series/{id} - used by gammaPublicGetSeriesId(general limit) # Search endpoints 'search': 0.667, # GET /search - used by gammaPublicGetSearch(300 req/10s = 1800 req/min) # Comment endpoints 'comments': 2.0, # GET /comments - used by gammaPublicGetComments(100 req/10s = 600 req/min) 'comments/{id}': 0.267, # GET /comments/{id} - used by gammaPublicGetCommentsId(general limit) # Sports endpoints 'sports': 0.267, # GET /sports - used by gammaPublicGetSports(general limit) 'sports/{id}': 0.267, # GET /sports/{id} - used by gammaPublicGetSportsId(general limit) }, }, }, # Data-API: https://data-api.polymarket.com # Rate limits: https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation: cost = (1000 / 50) * 60 / requests_per_minute = 1200 / requests_per_minute # - Data API(General): 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - Data API(Alternative): 1200 requests / 1 minute(20 req/s = 1200 req/min) => cost = 1.0 # - Data API /trades: 75 requests / 10s(7.5 req/s = 450 req/min) => cost = 2.67 # - Data API "OK" Endpoint: 10 requests / 10s(1 req/s = 60 req/min) => cost = 20.0 'data': { 'public': { 'get': { # Core endpoints(from Data-API) 'positions': 1.0, # GET /positions - used by dataPublicGetPositions(200 req/10s = 1200 req/min) 'trades': 2.67, # GET /trades - used by dataPublicGetTrades(75 req/10s = 450 req/min) 'activity': 1.0, # GET /activity - used by dataPublicGetActivity(200 req/10s = 1200 req/min) 'holders': 1.0, # GET /holders - used by dataPublicGetHolders(200 req/10s = 1200 req/min) 'value': 1.0, # GET /value - used by dataPublicGetTotalValue(200 req/10s = 1200 req/min) 'closed-positions': 1.0, # GET /closed-positions - used by dataPublicGetClosedPositions(200 req/10s = 1200 req/min) # Misc endpoints(from Data-API) 'traded': 1.0, # GET /traded - used by dataPublicGetTraded(200 req/10s = 1200 req/min) 'oi': 1.0, # GET /oi - used by dataPublicGetOpenInterest(200 req/10s = 1200 req/min) 'live-volume': 1.0, # GET /live-volume - used by dataPublicGetLiveVolume(200 req/10s = 1200 req/min) }, }, }, # Bridge API: https://bridge.polymarket.com # Rate limits: Not explicitly documented, using conservative general rate limits # Assuming similar to Data API: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 'bridge': { 'public': { 'get': { # Bridge endpoints 'supported-assets': 1.0, # GET /supported-assets - used by bridgePublicGetSupportedAssets(assumed 200 req/10s) }, 'post': { # Bridge endpoints 'deposit': 1.0, # POST /deposit - used by bridgePublicPostDeposit(assumed 200 req/10s) }, }, }, # CLOB API: https://clob.polymarket.com # Rate limits: https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation: cost = (1000 / 50) * 60 / requests_per_minute = 1200 / requests_per_minute # General CLOB Endpoints: # - CLOB(General): 5000 requests / 10s(500 req/s = 30000 req/min) => cost = 0.04 # - CLOB GET Balance Allowance: 125 requests / 10s(12.5 req/s = 750 req/min) => cost = 1.6 # - CLOB UPDATE Balance Allowance: 20 requests / 10s(2 req/s = 120 req/min) => cost = 10.0 # CLOB Market Data: # - CLOB /book: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB /books: 80 requests / 10s(8 req/s = 480 req/min) => cost = 2.5 # - CLOB /price: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB /prices: 80 requests / 10s(8 req/s = 480 req/min) => cost = 2.5 # - CLOB /midprice: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB /midprices: 80 requests / 10s(8 req/s = 480 req/min) => cost = 2.5 # CLOB Ledger Endpoints: # - CLOB Ledger(/trades /orders /notifications /order): 300 requests / 10s(30 req/s = 1800 req/min) => cost = 0.667 # - CLOB Ledger /data/orders: 150 requests / 10s(15 req/s = 900 req/min) => cost = 1.33 # - CLOB Ledger /data/trades: 150 requests / 10s(15 req/s = 900 req/min) => cost = 1.33 # - CLOB /notifications: 125 requests / 10s(12.5 req/s = 750 req/min) => cost = 1.6 # CLOB Markets & Pricing: # - CLOB Price History: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - CLOB Markets: 250 requests / 10s(25 req/s = 1500 req/min) => cost = 0.8 # - CLOB Market Tick Size: 50 requests / 10s(5 req/s = 300 req/min) => cost = 4.0 # - CLOB markets/0x: 50 requests / 10s(5 req/s = 300 req/min) => cost = 4.0 # - CLOB /markets listing: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # CLOB Authentication: # - CLOB API Keys: 50 requests / 10s(5 req/s = 300 req/min) => cost = 4.0 # CLOB Trading Endpoints(using sustained limits, not BURST): # - CLOB POST /order: 24000 requests / 10 minutes(40 req/s = 2400 req/min) => cost = 0.5 # - CLOB DELETE /order: 24000 requests / 10 minutes(40 req/s = 2400 req/min) => cost = 0.5 # - CLOB POST /orders: 12000 requests / 10 minutes(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB DELETE /orders: 12000 requests / 10 minutes(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB DELETE /cancel-all: 3000 requests / 10 minutes(5 req/s = 300 req/min) => cost = 4.0 # - CLOB DELETE /cancel-market-orders: 12000 requests / 10 minutes(20 req/s = 1200 req/min) => cost = 1.0 'clob': { 'public': { 'get': { # Order book endpoints 'orderbook': 1.0, # GET /book - used by fetchOrderBook(200 req/10s = 1200 req/min) 'orderbook/{token_id}': 0.04, # Not used(deprecated format, general limit) # Trade endpoints 'market/{condition_id}/trades': 0.04, # Not used(deprecated, use /trades instead, general limit) 'trades': 0.667, # GET /data/trades - used by fetchTrades(300 req/10s = 1800 req/min) # Price history endpoints 'prices-history': 2.0, # GET /prices-history - used by fetchOHLCV(100 req/10s = 600 req/min) # Pricing endpoints 'price': 1.0, # GET /price - available but using POST /prices instead(200 req/10s = 1200 req/min) 'prices': 2.5, # GET /prices - used by fetchTickers(80 req/10s = 480 req/min) # Midpoint endpoints 'midpoint': 1.0, # GET /midpoint - used by fetchTicker(200 req/10s = 1200 req/min) 'midpoints': 2.5, # GET /midpoints - available for fetchTickers enhancement(80 req/10s = 480 req/min) # Spread endpoints 'spread': 0.04, # GET /spread - available for fetchTicker enhancement(general limit) # Last trade price endpoints 'last-trade-price': 0.04, # GET /last-trade-price - available for ticker enhancement(general limit) 'last-trades-prices': 0.04, # GET /last-trades-prices - available for tickers enhancement(general limit) # Utility endpoints '': 4.0, # GET / - health check endpoint used by fetchStatus/clobPublicGetOk(50 req/10s = 300 req/min) 'time': 0.04, # GET /time - used by fetchTime(general limit) 'tick-size': 4.0, # GET /tick-size - used for market precision(50 req/10s = 300 req/min) 'neg-risk': 0.04, # GET /neg-risk - used for market metadata(general limit) 'fee-rate': 0.04, # GET /fee-rate - used by fetchTradingFee(general limit) 'markets': 2.0, # GET /markets - used by fetchMarkets(100 req/10s = 600 req/min) }, 'post': { # Order book endpoints 'books': 2.5, # POST /books - used by fetchOrderBooks(80 req/10s = 480 req/min) # Spread endpoints 'spreads': 0.04, # POST /spreads - used by fetchTickers(optional, general limit) # Pricing endpoints 'prices': 2.5, # POST /prices - used by fetchTicker(80 req/10s = 480 req/min) }, }, 'private': { 'get': { # Order endpoints 'order': 0.667, # GET /data/order/{order_id} - used by fetchOrder(300 req/10s = 1800 req/min) 'orders': 1.33, # GET /data/orders - used by fetchOrders, fetchOpenOrders(150 req/10s = 900 req/min) # Trade endpoints 'trades': 0.667, # GET /data/trades - used by fetchMyTrades(300 req/10s = 1800 req/min) 'builder-trades': 0.667, # GET /builder-trades - used for builder trades(300 req/10s = 1800 req/min) # Notification endpoints 'notifications': 1.6, # GET /notifications - used by getNotifications(125 req/10s = 750 req/min) # Balance endpoints 'balance-allowance': 1.6, # GET /balance-allowance - used by fetchBalance/getBalanceAllowance(125 req/10s = 750 req/min) # Order scoring endpoints 'order-scoring': 0.04, # GET /order-scoring - used by isOrderScoring(general limit) # API credential endpoints(L1 authentication - uses manual URL building) 'auth/derive-api-key': 4.0, # GET /auth/derive-api-key - used by derive_api_key(50 req/10s = 300 req/min) }, 'post': { # Order creation endpoints 'order': 0.5, # POST /order - used by createOrder(24000 req/10min = 2400 req/min sustained) 'orders': 1.0, # POST /orders - used by createOrders(12000 req/10min = 1200 req/min sustained) # Order scoring endpoints 'orders-scoring': 0.04, # POST /orders-scoring - used by areOrdersScoring(general limit) # API credential endpoints 'auth/api-key': 4.0, # POST /auth/api-key - used by create_or_derive_api_creds(50 req/10s = 300 req/min) }, 'delete': { # Order cancellation endpoints 'order': 0.5, # DELETE /order - used by cancelOrder(24000 req/10min = 2400 req/min sustained) 'orders': 1.0, # DELETE /orders - used by cancelOrders(12000 req/10min = 1200 req/min sustained) 'cancel-all': 4.0, # DELETE /cancel-all - used by cancelAllOrders(3000 req/10min = 300 req/min sustained) 'cancel-market-orders': 1.0, # DELETE /cancel-market-orders - used for canceling market orders(12000 req/10min = 1200 req/min sustained) # Notification endpoints 'notifications': 0.04, # DELETE /notifications - used by dropNotifications(general limit) }, 'put': { # Balance endpoints 'balance-allowance': 10.0, # PUT /balance-allowance - used by updateBalanceAllowance(20 req/10s = 120 req/min) }, }, }, }, 'timeframes': { '1m': '1m', '1h': '1h', '6h': '6h', '1d': '1d', '1w': '1w', }, 'fees': { 'trading': { 'tierBased': False, 'percentage': True, 'taker': self.parse_number('0.02'), # 2% taker fee(approximate) 'maker': self.parse_number('0.02'), # 2% maker fee(approximate) }, }, 'options': { 'fetchMarkets': { 'active': True, # only fetch active markets by default 'closed': False, 'archived': False, }, 'funder': None, # Address that holds funds(walletAddress, required for proxy wallets like email/Magic wallets) 'proxyWallet': None, # Proxy wallet address for Data-API endpoints(defaults to funder/walletAddress if not set) 'builderWallet': None, # Builder wallet address(defaults to funder/walletAddress if not set) 'signatureTypes': { # https://docs.polymarket.com/developers/CLOB/orders/orders#signature-types 'EOA': 0, # EIP712 signature signed by an EOA 'POLY_PROXY': 1, # EIP712 signatures signed by a signer associated with funding Polymarket proxy wallet 'POLY_GNOSIS_SAFE': 2, # EIP712 signatures signed by a signer associated with funding Polymarket gnosis safe wallet }, 'side': None, # Order side: 'BUY' or 'SELL'(default: None, must be provided) 'sides': { 'BUY': 0, # Buy side(maker gives USDC, wants tokens) 'SELL': 1, # Sell side(maker gives tokens, wants USDC) }, 'chainId': 137, # Chain ID: 137 = Polygon mainnet(default), 80001 = Polygon Mumbai testnet 'chainName': 'polygon-mainnet', # Chain name: 'polygon-mainnet'(default), 'polygon-mumbai'(testnet) 'sandboxMode': False, # Enable sandbox/testnet mode(uses Polygon Mumbai testnet) 'clobHost': None, # Custom CLOB API endpoint(defaults to https://clob.polymarket.com) 'defaultCollateral': 'USDC', # Default collateral currency 'defaultExpirationDays': 30, # Default expiration in days(default: 30 days from now) 'defaultFeeRateBps': 200, # Default fee rate fallback in basis points(default: 200 bps = 2%) 'defaultTickSize': '0.01', # Default tick size for rounding config(default: 0.01 = 2 decimal places for price, 2 for size, 4 for amount) 'marketOrderQuoteDecimals': 2, # Max decimal places for quote currency(USDC) in market orders(default: 2) 'marketOrderBaseDecimals': 4, # Max decimal places for base currency(tokens) in market orders(default: 4) 'roundingBufferDecimals': 4, # Additional decimal places buffer for rounding up before final rounding down(default: 4) # Constants matching clob-client # See https://github.com/Polymarket/clob-client/blob/main/src/signing/constants.ts # See https://github.com/Polymarket/clob-client/blob/main/src/constants.ts 'clobDomainName': 'ClobAuthDomain', 'clobVersion': '1', 'msgToSign': 'This message attests that I control the given wallet', 'initialCursor': 'MA==', # Base64 encoded empty string, matches clob-client INITIAL_CURSOR 'endCursor': 'LTE=', # Sentinel value indicating end of pagination 'defaultTokenId': None, # Default token ID for conditional tokens # Constants matching py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py 'zeroAddress': '0x0000000000000000000000000000000000000000', # Zero address for open orders(taker) # EIP-712 domain constants matching clob-order-utils # See https://github.com/Polymarket/clob-order-utils/blob/main/src/exchange.order.const.ts 'orderDomainName': 'Polymarket CTF Exchange', # EIP-712 domain name for orders(PROTOCOL_NAME) 'orderDomainVersion': '1', # EIP-712 domain version for orders(PROTOCOL_VERSION) # Contract addresses for all networks # See https://github.com/Polymarket/clob-client/blob/main/src/config.ts 'contracts': { # Polygon Amoy testnet(chainId: 80001) '80001': { 'exchange': '0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40', 'negRiskAdapter': '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', 'negRiskExchange': '0xC5d563A36AE78145C45a50134d48A1215220f80a', 'collateral': '0x9c4e1703476e875070ee25b56a58b008cfb8fa78', 'conditionalTokens': '0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB', }, # Polygon mainnet(chainId: 137) '137': { 'exchange': '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E', 'negRiskAdapter': '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', 'negRiskExchange': '0xC5d563A36AE78145C45a50134d48A1215220f80a', 'collateral': '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', 'conditionalTokens': '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', }, }, }, 'exceptions': { 'exact': { # HTTP status codes '400': BadRequest, # Bad Request - Invalid request parameters '401': AuthenticationError, # Unauthorized - Invalid or missing authentication '403': PermissionDenied, # Forbidden - Insufficient permissions '404': ExchangeError, # Not Found - Resource not found '429': RateLimitExceeded, # Too Many Requests - Rate limit exceeded '500': ExchangeError, # Internal Server Error '502': ExchangeError, # Bad Gateway '503': OnMaintenance, # Service Unavailable - Service temporarily unavailable '504': NetworkError, # Gateway Timeout # Common error messages(will be matched against error/message fields in response) 'Invalid signature': AuthenticationError, # Invalid signature in request 'Invalid API key': AuthenticationError, # Invalid or missing API key 'Invalid timestamp': AuthenticationError, # Invalid timestamp in request 'Signature expired': AuthenticationError, # Request timestamp is too old 'Unauthorized': AuthenticationError, # Authentication failed 'Forbidden': PermissionDenied, # Access denied 'Rate limit exceeded': RateLimitExceeded, # Rate limit exceeded 'Too many requests': RateLimitExceeded, # Too many requests 'Invalid order': InvalidOrder, # Order validation failed 'Invalid orderID': OrderNotFound, # Order does not exist 'Order not found': OrderNotFound, # Order does not exist 'Insufficient funds': InsufficientFunds, # Insufficient balance 'Insufficient balance': InsufficientFunds, # Insufficient balance 'Invalid market': BadRequest, # Invalid market/symbol 'Invalid symbol': BadRequest, # Invalid symbol 'Market not found': BadRequest, # Market does not exist 'Service unavailable': ExchangeNotAvailable, # Service temporarily unavailable 'Maintenance': OnMaintenance, # Service under maintenance }, 'broad': { 'authentication': AuthenticationError, # Any authentication-related error 'authorization': PermissionDenied, # Any authorization-related error 'rate limit': RateLimitExceeded, # Any rate limit error 'invalid order': InvalidOrder, # Any order validation error 'insufficient': InsufficientFunds, # Any insufficient funds/balance error 'not found': ExchangeError, # Any not found error 'timeout': NetworkError, # Any timeout error 'network': NetworkError, # Any network-related error 'maintenance': OnMaintenance, # Any maintenance-related error }, }, }) def get_signature_type(self, params={}): """ Helper method to get signature type from params or options with fallback to constants :param dict [params]: parameters that may contain signatureType or signature_type :returns number|None: signature type value """ signatureTypes = self.safe_dict(self.options, 'signatureTypes', {}) eoaSignatureType = self.safe_integer(signatureTypes, 'EOA') polyProxySignatureType = self.safe_integer(signatureTypes, 'POLY_PROXY') polyGnosisSafeSignatureType = self.safe_integer(signatureTypes, 'POLY_GNOSIS_SAFE') # Note: POLY_GNOSIS_SAFE is not supported for now proxyWalletAddress = self.get_proxy_wallet_address() mainWalletAddress = self.get_main_wallet_address() if proxyWalletAddress != mainWalletAddress: return polyProxySignatureType return eoaSignatureType def get_side(self, sideString: str, params={}): """ Helper method to get side from params or options with fallback to constants Converts BUY/SELL string to integer: BUY = 0, SELL = 1(matches UtilsBuy/UtilsSell from py-order-utils) :param str sideString: side('BUY' or 'SELL') :param dict [params]: parameters that may contain side or side_int :returns number: side(0 for BUY, 1 for SELL) """ # Check if side_int is provided directly in params sideInt = self.safe_integer(params, 'sideInt') or self.safe_integer(params, 'side_int') if sideInt is not None: return sideInt # Get sides enum from options sides = self.safe_dict(self.options, 'sides', {}) buySide = self.safe_integer(sides, 'BUY', 0) sellSide = self.safe_integer(sides, 'SELL', 1) # Convert side string to integer sideUpper = sideString.upper() sideValue = sellSide # Default to SELL if sideUpper == 'BUY': sideValue = buySide return sideValue async def fetch_markets(self, params={}) -> List[Market]: """ retrieves data on all markets for polymarket https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide#3-fetch-all-active-markets :param dict [params]: extra parameters specific to the exchange API endpoint :param boolean [params.active]: fetch active markets only(default: True) :param boolean [params.closed]: fetch closed markets :returns dict[]: an array of objects representing market data """ limit = 500 options = self.safe_dict(self.options, 'fetchMarkets', {}) request: dict = self.extend({ 'order': 'id', 'ascending': False, 'limit': limit, 'offset': 0, }, params) active = self.safe_bool(options, 'active', True) if self.safe_value(params, 'closed') is None: request['closed'] = not active offset = self.safe_integer(request, 'offset', 0) markets: List[Any] = [] while(True): pageRequest = self.extend(request, {'offset': offset}) response = await self.gamma_public_get_markets(pageRequest) page = self.safe_list(response, 'data', response) or [] markets = self.array_concat(markets, page) if len(page) < limit: break offset += limit filtered = [] for i in range(0, len(markets)): market = markets[i] id = self.safe_string(market, 'id') conditionId = self.safe_string(market, 'conditionId') or self.safe_string(market, 'condition_id') if id is None and conditionId is None: continue filtered.append(market) return self.parse_markets(filtered) def parse_market(self, market: dict) -> Market: # Schema uses 'conditionId'(camelCase) conditionId = self.safe_string(market, 'conditionId') question = self.safe_string(market, 'question') # Schema uses 'questionID'(camelCase) questionId = self.safe_string(market, 'questionID') # Schema uses 'slug'(camelCase) slug = self.safe_string(market, 'slug') active = self.safe_bool(market, 'active', False) closed = self.safe_bool(market, 'closed', False) archived = self.safe_bool(market, 'archived', False) outcomes = [] outcomePrices = [] outcomesStr = self.safe_string(market, 'outcomes') if outcomesStr is not None: parsedOutcomes = None try: parsedOutcomes = json.loads(outcomesStr) except Exception as e: parsedOutcomes = None if parsedOutcomes is not None and len(parsedOutcomes) is not None: for i in range(0, len(parsedOutcomes)): outcomes.append(parsedOutcomes[i]) else: outcomesArray = outcomesStr.split(',') for i in range(0, len(outcomesArray)): v = outcomesArray[i].strip() if v != '': outcomes.append(v) outcomePricesStr = self.safe_string(market, 'outcomePrices') if outcomePricesStr is not None: parsedPrices = None try: parsedPrices = json.loads(outcomePricesStr) except Exception as e: parsedPrices = None if parsedPrices is not None and len(parsedPrices) is not None: for i in range(0, len(parsedPrices)): outcomePrices.append(self.parse_number(parsedPrices[i])) else: pricesArray = outcomePricesStr.split(',') for i in range(0, len(pricesArray)): v = pricesArray[i].strip() if v != '': outcomePrices.append(self.parse_number(v)) # Use slug symbol if available baseId = slug or conditionId quoteId = self.safe_string(self.options, 'defaultCollateral', 'USDC') # Polymarket uses USDC currency # Market type - Polymarket is a prediction market platform marketType: MarketType = 'option' # Using 'option' match for prediction markets ammType = self.safe_string(market, 'ammType') # Schema uses 'enableOrderBook'(camelCase) enableOrderBook = self.safe_bool(market, 'enableOrderBook', False) # Market metadata category = self.safe_string(market, 'category') description = self.safe_string(market, 'description') tags = self.safe_value(market, 'tags', []) # Schema uses 'clobTokenIds'(camelCase) - can be string or array clobTokenIds = self.safe_value(market, 'clobTokenIds') if clobTokenIds is None: clobTokenIds = [] if isinstance(clobTokenIds, str): parsed = None try: parsed = json.loads(clobTokenIds) except Exception as e: parsed = None if parsed is not None and parsed != None and len(parsed) is not None: clobTokenIds = [] for i in range(0, len(parsed)): clobTokenIds.append(parsed[i]) else: cleaned = clobTokenIds cleaned = cleaned.replace('[', '').replace(']', '').replace('"', '') clobTokenIdsArray = cleaned.split(',') clobTokenIds = [] for i in range(0, len(clobTokenIdsArray)): v = clobTokenIdsArray[i].strip() if v != '': clobTokenIds.append(v) outcomesInfo = [] length = len(outcomes) if len(outcomePrices) > length: length = len(outcomePrices) if len(clobTokenIds) > length: length = len(clobTokenIds) for i in range(0, length): outcome = None if i < len(outcomes): outcome = outcomes[i] price = None if i < len(outcomePrices): price = self.parse_number(outcomePrices[i]) clobId = None if i < len(clobTokenIds): clobId = clobTokenIds[i] outcomeId = str(i) if clobId is not None: outcomeId = clobId outcomesInfo.append({ 'id': outcomeId, 'name': outcome, 'price': price, 'clobId': clobId, 'assetId': clobId, }) # Parse dates - Schema uses 'endDateIso'(preferred) or 'endDate'(fallback) endDateIso = self.safe_string(market, 'endDateIso') or self.safe_string(market, 'endDate') # Schema uses 'createdAt'(camelCase) createdAt = self.safe_string(market, 'createdAt') createdTimestamp = None if createdAt is not None: createdTimestamp = self.parse8601(createdAt) # Volume and liquidity volume = self.safe_string(market, 'volume') volumeNum = self.safe_number(market, 'volumeNum') liquidity = self.safe_string(market, 'liquidity') liquidityNum = self.safe_number(market, 'liquidityNum') feesEnabled = self.safe_bool(market, 'feesEnabled', False) makerBaseFee = self.safe_number(market, 'makerBaseFee') takerBaseFee = self.safe_number(market, 'takerBaseFee') base = baseId quote = quoteId settle = quote # Use quote # Parse expiry for option symbol formatting # Handle date-only strings(YYYY-MM-DD) by converting to ISO8601 datetime expiry = None expiryDatetime = endDateIso if endDateIso is not None: dateString = endDateIso # Check if it's a date-only string(YYYY-MM-DD format) if dateString.find(':') < 0: # Append time to make it a valid ISO8601 datetime dateString = dateString + 'T00:00:00Z' expiry = self.parse8601(dateString) # Format symbol with expiry date(similar to binance/okx option format) # Format: base/quote:settle-YYMMDD symbol = base + '/' + quote if expiry is not None: ymd = self.yymmdd(expiry) symbol = symbol + ':' + settle + '-' + ymd # Prediction markets don't have strike prices or option types in the schema # These fields are kept strike = None optionType = None contractSize = self.parse_number('1') # Calculate fees based on feesEnabled flag takerFee = self.parse_number('0') makerFee = self.parse_number('0') if feesEnabled: # Fees are enabled - use makerBaseFee and takerBaseFee from schema # These are typically in basis points(e.g., 200 = 2% = 0.02) if takerBaseFee is not None: takerFee = takerBaseFee / 10000 # Convert basis points to decimal if makerBaseFee is not None: makerFee = makerBaseFee / 10000 # Convert basis points to decimal created = self.milliseconds() # TODO change it if createdTimestamp is not None: created = createdTimestamp volumeValue = self.parse_number('0') if volumeNum is not None: volumeValue = volumeNum elif volume is not None: volumeValue = self.parse_number(volume) liquidityValue = self.parse_number('0') if liquidityNum is not None: liquidityValue = liquidityNum elif liquidity is not None: liquidityValue = self.parse_number(liquidity) return { 'id': conditionId, 'symbol': symbol, 'base': base, 'quote': quote, 'settle': settle, 'baseId': baseId, 'quoteId': quoteId, 'settleId': settle, 'type': marketType, 'spot': False, 'margin': False, 'swap': False, 'future': False, 'option': True, # Prediction markets are treated 'active': enableOrderBook and active and not closed and not archived, 'contract': True, 'linear': None, 'inverse': None, 'contractSize': contractSize, 'expiry': expiry, 'expiryDatetime': expiryDatetime, 'strike': strike, 'optionType': optionType, 'taker': takerFee, 'maker': makerFee, 'precision': { 'amount': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts 'price': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts }, 'limits': { 'leverage': { 'min': None, 'max': None, }, 'amount': { 'min': None, 'max': None, }, 'price': { 'min': 0, # Prediction markets are 0-1 'max': 1, # Prediction markets are 0-1 }, 'cost': { 'min': None, 'max': None, }, }, 'created': created, 'info': self.deep_extend(market, { 'outcomes': outcomes, 'outcomePrices': outcomePrices, 'outcomesInfo': outcomesInfo, 'question': question, 'slug': slug, 'category': category, 'description': description, 'tags': tags, 'condition_id': conditionId, 'question_id': questionId, 'asset_id': questionId, 'ammType': ammType, 'enableOrderBook': enableOrderBook, 'volume': volumeValue, 'liquidity': liquidityValue, 'endDateIso': endDateIso, 'createdAt': createdAt, 'createdTimestamp': createdTimestamp, 'clobTokenIds': clobTokenIds, 'quoteDecimals': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts 'baseDecimals': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts }), } async def fetch_order_book(self, symbol: str, limit: Int = None, params={}) -> OrderBook: """ fetches the order book for a market https://docs.polymarket.com/api-reference/orderbook/get-order-book-summary :param str symbol: unified symbol of the market to fetch the order book for :param int [limit]: the maximum amount of order book entries to return :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID for the specific outcome(required if market has multiple outcomes) :returns dict: A dictionary of `order book structures ` indexed by market symbols """ await self.load_markets() market = self.market(symbol) request: dict = {} # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchOrderBook() requires a token_id parameter for market ' + symbol) request['token_id'] = tokenId response = await self.clob_public_get_orderbook_token_id(self.extend(request, params)) return self.parse_order_book(response, symbol) async def fetch_order_books(self, symbols: Strings = None, limit: Int = None, params={}) -> OrderBooks: """ fetches information on open orders with bid(buy) and ask(sell) prices, volumes and other data for multiple markets https://docs.polymarket.com/developers/CLOB/clients/methods-public#getorderbooks :param str[]|None symbols: list of unified market symbols, all symbols fetched if None, default is None :param int [limit]: the maximum amount of order book entries to return :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: a dictionary of `order book structures ` indexed by market symbol """ await self.load_markets() if symbols is None: symbols = self.symbols # Build list of token IDs to fetch order books for tokenIds: List[str] = [] tokenIdToSymbol: dict = {} for i in range(0, len(symbols)): symbol = symbols[i] market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: return {} # Fetch order books for all token IDs at once using POST /books endpoint # See https://docs.polymarket.com/api-reference/orderbook/get-multiple-order-books-summaries-by-request # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: array of order book objects, each with asset_id matching token_id requestBody = [] for i in range(0, len(tokenIds)): requestItem: dict = {'token_id': tokenIds[i]} if limit is not None: requestItem['limit'] = limit requestBody.append(requestItem) response = await self.clob_public_post_books(self.extend({'requests': requestBody}, params)) # Parse response: array of order book objects, each with asset_id field # Response is directly an array: [{asset_id: "...", bids: [...], asks: [...]}, ...] result: dict = {} if isinstance(response, list): for i in range(0, len(response)): orderbookData = response[i] assetId = self.safe_string(orderbookData, 'asset_id') symbol = tokenIdToSymbol[assetId] if symbol is not None: try: orderbook = self.parse_order_book(orderbookData, symbol) result[symbol] = orderbook except Exception as e: # Skip markets that fail to parse continue return result def parse_order_book(self, orderbook: dict, symbol: Str = None, timestamp: Int = None, bidsKey: Str = 'bids', asksKey: Str = 'asks', priceKey: Int = 0, amountKey: Int = 1, countOrIdKey: Int = 2) -> OrderBook: # Polymarket CLOB orderbook format(from /book endpoint) # See https://docs.polymarket.com/developers/CLOB/clients/methods-public#getorderbook # { # "market": "string", # "asset_id": "string", # "timestamp": "string", # "bids": [ # { # "price": "0.65", # string # "size": "100" # string # } # ], # "asks": [ # { # "price": "0.66", # string # "size": "50" # string # } # ], # "min_order_size": "string", # "tick_size": "string", # "neg_risk": boolean, # "hash": "string" # } # Note: Ensure bids and asks are always arrays to avoid Python transpilation issues # safeList can return None, which becomes None in Python, causing len() to fail bids = self.safe_list(orderbook, 'bids', []) or [] asks = self.safe_list(orderbook, 'asks', []) or [] # Note: Using 'const' without explicit type annotation to avoid Python transpilation issues # The transpiler incorrectly preserves TypeScript tuple type annotations(e.g., ': [number, number][]') in Python code parsedBids = [] parsedAsks = [] for i in range(0, len(bids)): bid = bids[i] price = self.safe_number(bid, 'priceNumber', self.safe_number(bid, 'price')) amount = self.safe_number(bid, 'sizeNumber', self.safe_number(bid, 'size')) if price is not None and amount is not None: parsedBids.append([price, amount]) for i in range(0, len(asks)): ask = asks[i] price = self.safe_number(ask, 'priceNumber', self.safe_number(ask, 'price')) amount = self.safe_number(ask, 'sizeNumber', self.safe_number(ask, 'size')) if price is not None and amount is not None: parsedAsks.append([price, amount]) # Extract timestamp from orderbook response if available orderbookTimestamp = self.safe_string(orderbook, 'timestamp') finalTimestamp = timestamp if orderbookTimestamp is not None: # CLOB API returns timestamp string, convert to milliseconds finalTimestamp = self.parse8601(orderbookTimestamp) # Extract tick_size and neg_risk from orderbook if available(useful metadata) # These are also available via get_tick_size() and get_neg_risk() endpoints # Based on py-clob-client: get_tick_size() and get_neg_risk() tickSize = self.safe_string(orderbook, 'tick_size') negRisk = self.safe_bool(orderbook, 'neg_risk') minOrderSize = self.safe_string(orderbook, 'min_order_size') result: OrderBook = { 'symbol': symbol, 'bids': parsedBids, 'asks': parsedAsks, 'timestamp': finalTimestamp, 'datetime': self.iso8601(finalTimestamp), 'nonce': None, } # Include tick_size, neg_risk, and min_order_size in info if available(useful metadata) if tickSize is not None or negRisk is not None or minOrderSize is not None: metadata: dict = {} if tickSize is not None: metadata['tick_size'] = tickSize if negRisk is not None: metadata['neg_risk'] = negRisk if minOrderSize is not None: metadata['min_order_size'] = minOrderSize result['info'] = self.extend(orderbook, metadata) return result async def fetch_ticker(self, symbol: str, params={}) -> Ticker: """ fetches a price ticker, a statistical calculation with the information calculated over the past 24 hours for a specific market https://docs.polymarket.com/api-reference/pricing/get-market-price https://docs.polymarket.com/api-reference/pricing/get-midpoint-price :param str symbol: unified symbol of the market to fetch the ticker for :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID for the specific outcome(required if market has multiple outcomes) :param str [params.side]: the side: 'BUY' or 'SELL'(default: 'BUY') :returns dict: a `ticker structure ` **Currently Populated Fields:** - `bid` - Best bid price from POST /prices endpoint(BUY side) - `ask` - Best ask price from POST /prices endpoint(SELL side) - `last` - Midpoint price from GET /midpoint or lastTradePrice from market info - `open` - Calculated approximation: last / (1 + oneDayPriceChange) - `change` - Calculated: last - open - `percentage` - From oneDayPriceChange * 100(from market info) - `volume` - From volumeNum or volume(from market info) - `timestamp` - From updatedAt(parsed from ISO string) - `datetime` - ISO8601 formatted timestamp **Currently Undefined Fields(Available via Additional API Calls):** - `high` - Can be fetched from GET /prices-history(24h price history) or GET /trades(24h trades) - `low` - Can be fetched from GET /prices-history(24h price history) or GET /trades(24h trades) - `bidVolume` - Can be calculated from GET /book(order book) by summing all bid sizes - `askVolume` - Can be calculated from GET /book(order book) by summing all ask sizes - `vwap` - Can be calculated from GET /trades(24h trades) using volume-weighted average - `average` - Not available - `indexPrice` - Not available - `markPrice` - Not available **Enhancement Options:** 1. **For High/Low/More Accurate Open:** - Use fetchOHLCV() to get 24h price history: `await exchange.fetchOHLCV(symbol, '1h', since24hAgo, None, {token_id: tokenId})` - Calculate high/low from OHLCV data - Use first candle's open price for accurate 24h open - API: GET /prices-history(see https://docs.polymarket.com/developers/CLOB/timeseries) 2. **For VWAP:** - Use fetchTrades() to get 24h trades: `await exchange.fetchTrades(symbol, since24hAgo, None, {token_id: tokenId})` - Calculate: vwap = sum(trade.cost) / sum(trade.amount) - API: GET /trades(see https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets) 3. **For Bid/Ask Volumes:** - Use fetchOrderBook() to get order book: `await exchange.fetchOrderBook(symbol, None, {token_id: tokenId})` - Calculate: bidVolume = sum of all bid[1](sizes), askVolume = sum of all ask[1](sizes) - API: GET /book(see https://docs.polymarket.com/api-reference/orderbook/get-order-book-summary) 4. **For More Accurate Last Price:** - Use GET /last-trade-price endpoint: `await exchange.clobPublicGetLastTradePrice({token_id: tokenId})` - API: GET /last-trade-price(see https://docs.polymarket.com/api-reference/trades/get-last-trade-price) """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchTicker() requires a token_id parameter for market ' + symbol) # Fetch prices using POST /prices endpoint with both BUY and SELL sides # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request pricesResponse = await self.clob_public_post_prices(self.extend({ 'requests': [ {'token_id': tokenId, 'side': 'BUY'}, {'token_id': tokenId, 'side': 'SELL'}, ], }, params)) # Parse prices response: {[token_id]: {BUY: "price", SELL: "price"}, ...} tokenPrices = self.safe_dict(pricesResponse, tokenId, {}) buyPrice = self.safe_string(tokenPrices, 'BUY') sellPrice = self.safe_string(tokenPrices, 'SELL') # Fetch midpoint if available(optional, ignore if not provided) midpoint = None try: midpointResponse = await self.clob_public_get_midpoint(self.extend({'token_id': tokenId}, params)) midpoint = self.safe_string(midpointResponse, 'mid') except Exception as e: # Ignore midpoint if not available or fails midpoint = None # Combine pricing data with market info - already loaded from fetchMarkets combinedData = self.deep_extend(marketInfo, { 'buyPrice': buyPrice, 'sellPrice': sellPrice, 'midpoint': midpoint, }) return self.parse_ticker(combinedData, market) async def fetch_tickers(self, symbols: Strings = None, params={}) -> Tickers: """ fetches price tickers for multiple markets, statistical information calculated over the past 24 hours for each market https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices :param str[]|None symbols: unified symbols of the markets to fetch the ticker for, all market tickers are returned if not assigned :param dict [params]: extra parameters specific to the exchange API endpoint :param boolean [params.fetchSpreads]: if True, also fetch bid-ask spreads for all markets(default: False) :returns dict: a dictionary of `ticker structures ` """ await self.load_markets() # Build list of token IDs to fetch prices for tokenIds: List[str] = [] tokenIdToSymbol: dict = {} symbolsToFetch = symbols or self.symbols for i in range(0, len(symbolsToFetch)): symbol = symbolsToFetch[i] market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: return {} # Build requests array for POST /prices endpoint # Each token needs both BUY and SELL sides # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request requests = [] for i in range(0, len(tokenIds)): tokenId = tokenIds[i] requests.append({'token_id': tokenId, 'side': 'BUY'}) requests.append({'token_id': tokenId, 'side': 'SELL'}) # Fetch prices for all token IDs at once using POST /prices endpoint # Response format: {[token_id]: {BUY: "price", SELL: "price"}, ...} # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request pricesResponse = await self.clob_public_post_prices(self.extend({'requests': requests}, params)) # Optionally fetch spreads for all token IDs # See https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spreads fetchSpreads = self.safe_bool(params, 'fetchSpreads', False) spreadsResponse = {} if fetchSpreads: try: spreadsResponse = await self.clob_public_post_spreads(self.extend({'token_ids': tokenIds}, params)) except Exception as e: spreadsResponse = {} # Build market data map for efficient lookup tokenIdToMarket = {} for i in range(0, len(tokenIds)): tokenId = tokenIds[i] symbol = tokenIdToSymbol[tokenId] tokenIdToMarket[tokenId] = self.market(symbol) # Parse prices and build tickers(no additional fetching during parsing) tickers: dict = {} for i in range(0, len(tokenIds)): tokenId = tokenIds[i] symbol = tokenIdToSymbol[tokenId] market = tokenIdToMarket[tokenId] try: # Get prices from the response(both BUY and SELL are in the same response) tokenPrices = self.safe_dict(pricesResponse, tokenId, {}) buyPrice = self.safe_string(tokenPrices, 'BUY') sellPrice = self.safe_string(tokenPrices, 'SELL') # Get spread if available spread = self.safe_string(spreadsResponse, tokenId) # Use market info data(already loaded from fetchMarkets) marketInfo = self.safe_dict(market, 'info', {}) # Combine pricing data with market info combinedData = self.deep_extend(marketInfo, { 'buyPrice': buyPrice, 'sellPrice': sellPrice, 'spread': spread, }) ticker = self.parse_ticker(combinedData, market) tickers[symbol] = ticker except Exception as e: # Skip markets that fail to parse continue return tickers def parse_ticker(self, ticker: dict, market: Market = None) -> Ticker: """ parses a ticker data structure from Polymarket API response :param dict ticker: ticker data structure from Polymarket API :param dict [market]: market structure :returns dict: a `ticker structure ` **Data Sources:** - Market info from fetchMarkets()(volume, oneDayPriceChange, lastTradePrice, etc.) - Pricing API(buyPrice, sellPrice, midpoint) - Market metadata(updatedAt, volume24hr, volume1wk, volume1mo, volume1yr) **Currently Parsed Fields:** - `bid` - From buyPrice(POST /prices BUY side) or bestBid(market info) - `ask` - From sellPrice(POST /prices SELL side) or bestAsk(market info) - `last` - From midpoint(GET /midpoint) or lastTradePrice(market info) - `open` - Calculated: last / (1 + oneDayPriceChange) when both available - `change` - Calculated: last - open - `percentage` - From oneDayPriceChange * 100 - `volume` - From volumeNum or volume(market info) - `timestamp` - From updatedAt(ISO string parsed to milliseconds) - `datetime` - ISO8601 formatted timestamp **Fields Set to Undefined(Can Be Enhanced):** - `high` - Not available in current data sources. Can be calculated from: - Price history: Math.max(...ohlcvData.map(c => c[2])) where c[2] is high - Trades: Math.max(...trades.map(t => t.price)) - `low` - Not available in current data sources. Can be calculated from: - Price history: Math.min(...ohlcvData.map(c => c[3])) where c[3] is low - Trades: Math.min(...trades.map(t => t.price)) - `bidVolume` - Not available. Can be calculated from order book: - orderbook.bids.reduce((sum, bid) => sum + bid[1], 0) - `askVolume` - Not available. Can be calculated from order book: - orderbook.asks.reduce((sum, ask) => sum + ask[1], 0) - `vwap` - Not available. Can be calculated from trades: - totalCost = trades.reduce((sum, t) => sum + t.cost, 0) - totalVolume = trades.reduce((sum, t) => sum + t.amount, 0) - vwap = totalCost / totalVolume **To Enhance Ticker Data:** Before calling parseTicker(), you can fetch additional data and add it to the ticker dict: ```typescript # Example: Add high/low from price history since24h = exchange.milliseconds() - 24 * 60 * 60 * 1000 ohlcv = await exchange.fetchOHLCV(symbol, '1h', since24h, None, {token_id: tokenId}) if len(ohlcv) > 0: highs = ohlcv.map(c => c[2]) # OHLCV[2] is high lows = ohlcv.map(c => c[3]) # OHLCV[3] is low ticker['high'] = Math.max(...highs) ticker['low'] = Math.min(...lows) ticker['open'] = ohlcv[0][1] # First candle's open } # Example: Add VWAP from trades trades = await exchange.fetchTrades(symbol, since24h, None, {token_id: tokenId}) if len(trades) > 0: totalCost = 0 totalVolume = 0 for i in range(0, len(trades)): totalCost += trades[i]['cost'] totalVolume += trades[i]['amount'] } ticker['vwap'] = totalVolume > totalCost / totalVolume if 0 else None } # Example: Add bid/ask volumes from order book orderbook = await exchange.fetchOrderBook(symbol, None, {token_id: tokenId}) bidVolume = 0 askVolume = 0 for i in range(0, len(orderbook['bids'])): bidVolume += orderbook['bids'][i][1] } for i in range(0, len(orderbook['asks'])): askVolume += orderbook['asks'][i][1] } ticker['bidVolume'] = bidVolume ticker['askVolume'] = askVolume ``` """ # Polymarket ticker format from market data symbol = market['symbol'] if market else None # Parse outcome prices outcomePricesStr = self.safe_string(ticker, 'outcomePrices') outcomePrices = [] if outcomePricesStr: try: parsed = json.loads(outcomePricesStr) # Note: Ensure all elements are numbers - json.loadsmay return strings # Convert each element to a number to avoid Python multiplication errors if parsed is not None and parsed != None and len(parsed) is not None: for i in range(0, len(parsed)): price = self.parse_number(parsed[i]) if price is not None: outcomePrices.append(price) except Exception as e: # Note: Using for loop instead of .map() to avoid Python transpilation issues # Arrow functions with type annotations(e.g., '(p: string) =>') are incorrectly preserved in Python pricesArray = outcomePricesStr.split(',') for i in range(0, len(pricesArray)): price = self.parse_number(pricesArray[i].strip()) if price is not None: outcomePrices.append(price) last = None bid = None ask = None high = None low = None # Volume data volume = self.safe_number(ticker, 'volumeNum', self.safe_number(ticker, 'volume')) volume24hr = self.safe_number(ticker, 'volume24hr') volume1wk = self.safe_number(ticker, 'volume1wk') volume1mo = self.safe_number(ticker, 'volume1mo') volume1yr = self.safe_number(ticker, 'volume1yr') # Price changes oneDayPriceChange = self.safe_number(ticker, 'oneDayPriceChange') # Best bid/ask from pricing API(BUY = bid, SELL = ask) buyPrice = self.safe_number(ticker, 'buyPrice') sellPrice = self.safe_number(ticker, 'sellPrice') midpoint = self.safe_number(ticker, 'midpoint') # Use pricing API data if available if buyPrice is not None: bid = buyPrice if sellPrice is not None: ask = sellPrice if midpoint is not None: last = midpoint # Fallback to ticker data if pricing API data not available bestBid = self.safe_number(ticker, 'bestBid') bestAsk = self.safe_number(ticker, 'bestAsk') lastTradePrice = self.safe_number(ticker, 'lastTradePrice') if bid is None and bestBid is not None: bid = bestBid if ask is None and bestAsk is not None: ask = bestAsk if last is None and lastTradePrice is not None: last = lastTradePrice # Timestamp updatedAtString = self.safe_string(ticker, 'updatedAt') timestamp = self.parse8601(updatedAtString) if updatedAtString else None datetime = self.iso8601(timestamp) if timestamp else None # Open(previous closing price - approximated) open = last is not None and oneDayPriceChange is not last / (1 + oneDayPriceChange) if None else None # Change and percentage change = last is not None and open is not last - open if None else None percentage = oneDayPriceChange is not oneDayPriceChange * 100 if None else None # Add additional Polymarket-specific fields to info tickerInfo = self.safe_dict(ticker, 'info', {}) extendedInfo = self.deep_extend(tickerInfo, { 'buyPrice': buyPrice, 'sellPrice': sellPrice, 'midpoint': midpoint, 'lastTradePrice': lastTradePrice, 'volume24hr': volume24hr, 'volume1wk': volume1wk, 'volume1mo': volume1mo, 'volume1yr': volume1yr, }) return { 'symbol': symbol, 'info': self.deep_extend(ticker, {'info': extendedInfo}), 'timestamp': timestamp, 'datetime': datetime, 'high': high, 'low': low, 'bid': bid, 'bidVolume': None, 'ask': ask, 'askVolume': None, 'vwap': None, 'open': open, 'close': last, 'last': last, 'previousClose': open, 'change': change, 'percentage': percentage, 'average': None, 'baseVolume': volume, 'quoteVolume': volume, 'indexPrice': None, 'markPrice': None, } async def fetch_trades(self, symbol: str, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ get the list of most recent trades for a particular symbol https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets :param str symbol: unified symbol of the market to fetch trades for :param int [since]: timestamp in ms of the earliest trade to fetch :param int [limit]: the maximum amount of trades to fetch(default: 100, max: 10000) :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.offset]: offset for pagination(default: 0, max: 10000) :param boolean [params.takerOnly]: if True, returns only trades where the user is the taker(default: True) :param str [params.side]: filter by side: 'BUY' or 'SELL' :returns Trade[]: a list of `trade structures ` """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get condition_id from market info(self is the market ID for Polymarket) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) request: dict = { 'market': [conditionId], # Data API expects an array of condition IDs } # Note: Data API /trades endpoint supports limit(default: 100, max: 10000) and offset for pagination # The 'since' parameter is not directly supported by the REST API if limit is not None: request['limit'] = min(limit, 10000) # Cap at max 10000 offset = self.safe_integer(params, 'offset') if offset is not None: request['offset'] = offset takerOnly = self.safe_bool(params, 'takerOnly', True) request['takerOnly'] = takerOnly side = self.safe_string_upper(params, 'side') if side is not None: request['side'] = side response = await self.data_public_get_trades(self.extend(request, self.omit(params, ['offset', 'takerOnly', 'side']))) tradesData = [] if isinstance(response, list): tradesData = response else: dataList = self.safe_list(response, 'data', []) if dataList is not None: tradesData = dataList return self.parse_trades(tradesData, market, since, limit) def parse_trade(self, trade: dict, market: Market = None) -> Trade: # Detect Data API format(has conditionId field) vs CLOB format(has market/asset_id fields) # Check for both camelCase and snake_case for robustness conditionId = self.safe_string_2(trade, 'conditionId', 'condition_id') isDataApiFormat = conditionId is not None if isDataApiFormat: # Data API format: https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets # { # "proxyWallet": "0x...", # "side": "BUY", # "asset": "", # "conditionId": "0x...", # "size": 123, # "price": 123, # "timestamp": 123, # "transactionHash": "0x...", # ... # } # Use transactionHash, check both camelCase and snake_case id = self.safe_string_2(trade, 'transactionHash', 'transaction_hash') symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif conditionId is not None: resolved = self.safe_market(conditionId, None) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = conditionId timestampSeconds = self.safe_integer(trade, 'timestamp') timestamp = None if timestampSeconds is not None: timestamp = timestampSeconds * 1000 side = self.safe_string_lower(trade, 'side') price = self.safe_number(trade, 'price') amount = self.safe_number(trade, 'size') cost = None if price is not None and amount is not None: cost = price * amount # Data API doesn't provide fee information return { 'id': id, 'info': trade, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp), 'symbol': symbol, 'type': None, 'side': side, 'takerOrMaker': None, # Data API doesn't provide self information 'price': price, 'amount': amount, 'cost': cost, 'fee': None, # Data API doesn't provide fee information 'order': None, # Data API doesn't provide order ID } else: # CLOB Trade format(backward compatibility) # interface Trade { # id: string # taker_order_id: string # market: string # asset_id: string # side: Side # size: string # fee_rate_bps: string # price: string # status: string # match_time: string # last_update: string # outcome: string # bucket_index: number # owner: string # maker_address: string # maker_orders: MakerOrder[] # transaction_hash: string # trader_side: "TAKER" | "MAKER" # } id = self.safe_string(trade, 'id') assetId = self.safe_string(trade, 'asset_id') tradeMarket = self.safe_string(trade, 'market') symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif tradeMarket is not None: resolved = self.safe_market(tradeMarket, None) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = tradeMarket elif assetId is not None: resolved = self.safe_market(assetId, market) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = assetId matchTime = self.safe_integer(trade, 'match_time') timestamp = None if matchTime is not None: timestamp = matchTime * 1000 # Top-level fields are from the taker perspective; for maker trades use maker_orders side = self.safe_string_lower(trade, 'side') price = self.safe_number(trade, 'price') amount = self.safe_number(trade, 'size') feeRateBps = self.safe_number(trade, 'fee_rate_bps') traderSide = self.safe_string_upper(trade, 'trader_side') if traderSide == 'MAKER': makerOrders = self.safe_value(trade, 'maker_orders', []) proxyWallet = self.get_proxy_wallet_address() userAddress = proxyWallet.lower() matched = False for i in range(0, len(makerOrders)): m = makerOrders[i] mAddr = self.safe_string(m, 'maker_address') if mAddr is not None: mAddrLower = mAddr.lower() if mAddrLower == userAddress: price = self.safe_number(m, 'price') amount = self.safe_number(m, 'matched_amount') side = self.safe_string_lower(m, 'side') feeRateBps = self.safe_number(m, 'fee_rate_bps') matched = True break if not matched: m = makerOrders[0] price = self.safe_number(m, 'price') amount = self.safe_number(m, 'matched_amount') side = self.safe_string_lower(m, 'side') feeRateBps = self.safe_number(m, 'fee_rate_bps') feeCost = None if feeRateBps is not None and price is not None and amount is not None: feeCost = price * amount * feeRateBps / 10000 fee = None if feeCost is not None: fee = { 'cost': feeCost, 'currency': self.safe_string(self.options, 'defaultCollateral', 'USDC'), 'rate': feeRateBps is not feeRateBps / 10000 if None else None, } cost = price * amount if (price is not None and amount is not None) else None return { 'id': id, 'info': trade, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp), 'symbol': symbol, 'type': None, 'side': side, 'takerOrMaker': self.safe_string_lower(trade, 'trader_side'), 'price': price, 'amount': amount, 'cost': cost, 'fee': fee, 'order': self.safe_string(trade, 'taker_order_id'), } async def fetch_ohlcv(self, symbol: str, timeframe: str = '1h', since: Int = None, limit: Int = None, params={}) -> List[list]: """ fetches historical candlestick data containing the open, high, low, and close price, and the volume of a market https://docs.polymarket.com/developers/CLOB/clients/methods-public#getpriceshistory :param str symbol: unified symbol of the market to fetch OHLCV data for :param str timeframe: the length of time each candle represents :param int [since]: timestamp in ms of the earliest candle to fetch :param int [limit]: the maximum amount of candles to fetch :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID for the specific outcome(required if market has multiple outcomes) :param int [params.endTs]: timestamp in seconds for the ending date filter :param number [params.fidelity]: data fidelity/quality :returns int[][]: A list of candles ordered, open, high, low, close, volume """ await self.load_markets() market = self.market(symbol) request: dict = {} # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchOHLCV() requires a token_id parameter for market ' + symbol) request['market'] = tokenId # API uses 'market' parameter for token_id # Note: REST API /prices-history endpoint requires either: # 1. startTs and endTs(mutually exclusive with interval) # 2. interval(mutually exclusive with startTs/endTs) # See https://docs.polymarket.com/developers/CLOB/timeseries # Supported intervals: "1m", "1h", "6h", "1d", "1w", "max" # CCXT will automatically reject unsupported timeframes based on the 'timeframes' definition endTs = self.safe_integer(params, 'endTs') if since is not None or endTs is not None: # Use startTs/endTs when time range is specified if since is not None: # Convert milliseconds to seconds for API request['startTs'] = self.parse_to_int(since / 1000) if endTs is not None: request['endTs'] = endTs else: # Use interval when no time range is specified # CCXT will validate the timeframe against the 'timeframes' definition # Map to API format(timeframe should already be validated by CCXT) request['interval'] = timeframe # Fidelity parameter controls data granularity(e.g., 720 for 12-hour intervals) # If not provided, API may use default fidelity fidelity = self.safe_number(params, 'fidelity') # Polymarket enforces minimum fidelity per interval(e.g. interval=1m requires fidelity>=10) # Avoid leaking a server-side validation error back to the user when a too-low value is supplied. if timeframe == '1m': if fidelity is None: fidelity = 10 else: fidelity = min(10, fidelity) if fidelity is not None: request['fidelity'] = fidelity remainingParams = self.omit(params, ['token_id', 'endTs', 'fidelity']) response = await self.clob_public_get_prices_history(self.extend(request, remainingParams)) ohlcvData = [] if isinstance(response, list): ohlcvData = response else: # Response has 'history' key containing the array ohlcvData = self.safe_list(response, 'history', []) or [] return self.parse_ohlcvs(ohlcvData, market, timeframe, since, limit) def parse_ohlcv(self, ohlcv: Any, market: Market = None) -> list: # Polymarket MarketPrice format from getPricesHistory # See https://docs.polymarket.com/developers/CLOB/clients/methods-public#getpriceshistory # { # "t": number, # timestamp in seconds # "p": number # price # } # Note: Polymarket only returns price data, not full OHLCV # We'll use the price, high, low, and close, with volume timestamp = self.safe_integer(ohlcv, 't') price = self.safe_number(ohlcv, 'p') # Convert timestamp from seconds to milliseconds if timestamp is not None: timestamp = timestamp * 1000 return [ timestamp, price, # open price, # high(same since we only have price) price, # low(same since we only have price) price, # close 0, # volume(not available in price history) ] def get_rounding_config(self, tickSize: str) -> dict: """ Get rounding configuration based on tick size(matches ROUNDING_CONFIG from official SDK) :param str tickSize: tick size string(e.g., '0.1', '0.01', '0.001', '0.0001') :returns dict: rounding configuration with price, size, and amount decimal places """ # Determine rounding config based on tick size(matches ROUNDING_CONFIG from SDK) # Returns: {price: number, size: number, amount: number} priceDecimals = 2 sizeDecimals = 2 amountDecimals = 4 if tickSize == '0.1': priceDecimals = 1 sizeDecimals = 2 amountDecimals = 3 elif tickSize == '0.01': priceDecimals = 2 sizeDecimals = 2 amountDecimals = 4 elif tickSize == '0.001': priceDecimals = 3 sizeDecimals = 2 amountDecimals = 5 elif tickSize == '0.0001': priceDecimals = 4 sizeDecimals = 2 amountDecimals = 6 return { 'price': priceDecimals, 'size': sizeDecimals, 'amount': amountDecimals, } def round_down(self, value: str, decimals: float) -> str: """ Round down(truncate) a value to specific decimal places :param str value: value to round down :param number decimals: number of decimal places :returns str: rounded down value """ return self.decimal_to_precision(value, 0, decimals, 2, 5) def round_normal(self, value: str, decimals: float) -> str: """ Round a value normally to specific decimal places :param str value: value to round :param number decimals: number of decimal places :returns str: rounded value """ return self.decimal_to_precision(value, 1, decimals, 2, 5) def round_up(self, value: str, decimals: float) -> str: """ Round up a value to specific decimal places :param str value: value to round up :param number decimals: number of decimal places :returns str: rounded up value """ return self.decimal_to_precision(value, 2, decimals, 2, 5) def decimal_places(self, value: str) -> float: """ Count the number of decimal places in a string value :param str value: value to count decimal places for :returns number: number of decimal places """ parts = value.split('.') if len(parts) == 2: return len(parts[1]) return 0 def to_token_decimals(self, value: str, decimals: float) -> str: """ Convert a value to token decimals(smallest unit) by multiplying by 10^decimals and truncating :param str value: value to convert :param number decimals: number of decimals(e.g., 6 for USDC, 18 for tokens) :returns str: value in smallest unit """ # Multiply by 10^decimals and truncate to integer multiplier = self.integer_precision_to_amount(self.number_to_string(-decimals)) return Precise.string_div(Precise.string_mul(value, multiplier), '1', 0) async def build_and_sign_order(self, tokenId: str, side: str, size: str, price: str = None, market: Market = None, params={}) -> dict: """ Builds and signs an order with EIP-712 according to Polymarket order-utils specification https://github.com/Polymarket/clob-order-utils https://github.com/Polymarket/clob-client/blob/main/src/order-builder/builder.ts https://github.com/Polymarket/python-order-utils/blob/main/py_order_utils/builders/order_builder.py :param str tokenId: the token ID :param str side: 'BUY' or 'SELL' :param str size: order size :param str [price]: order price(required for limit orders) :param dict [market]: market structure(optional, used to get fees) :param dict [params]: extra parameters :param number [params.expiration]: expiration timestamp in seconds(default: 30 days from now) :param number [params.nonce]: order nonce(default: 0) :param number [params.feeRateBps]: fee rate in basis points(default: from market or 200 bps) :param str [params.maker]: maker address(default: getMainWalletAddress()) :param str [params.taker]: taker address(default: zero address) :param str [params.signer]: signer address(default: maker address) :param number [params.signatureType]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :param str [params.orderType]: order type: 'GTC', 'IOC', 'FOK', 'GTD'(default: 'GTC' for limit orders, 'FOK' for market orders) :returns dict: signed order object ready for submission """ # Get zero address constant(matches py-clob-client ZERO_ADDRESS) # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py zeroAddress = self.safe_string(self.options, 'zeroAddress', '0x0000000000000000000000000000000000000000') # Get signature type signatureType = self.get_signature_type(params) # Get maker address(wallet address) - checksummed for signing maker = self.safe_string(params, 'maker') if maker is None: signatureTypes = self.safe_dict(self.options, 'signatureTypes', {}) eoaSignatureType = self.safe_integer(signatureTypes, 'EOA') if signatureType == eoaSignatureType: maker = self.get_main_wallet_address() else: maker = self.get_proxy_wallet_address() normalizedMaker = self.normalize_address(maker) # Get taker address(default: zero address for open orders) taker = self.safe_string(params, 'taker', zeroAddress) normalizedTaker = self.normalize_address(taker) # Get fee rate in basis points from market or params feeRateBps = self.safe_integer(params, 'feeRateBps') if feeRateBps is None: if market is not None: # Try to get fee from market structure marketInfo = self.safe_dict(market, 'info', {}) # First try takerBaseFee from market info(in basis points) feeRateBps = self.safe_integer(marketInfo, 'takerBaseFee') if feeRateBps is None: # Try taker fee from parsed market(decimal, convert to basis points) takerFee = self.safe_number(market, 'taker') if takerFee is not None: feeRateBps = int(round(takerFee * 10000)) # Fallback to default fee rate from options if not found in market if feeRateBps is None: feeRateBps = self.safe_integer(self.options, 'defaultFeeRateBps', 200) # Get expiration(default: from options.defaultExpirationDays, or 30 days from now in seconds) expiration = self.safe_integer(params, 'expiration') if expiration is None: nowSeconds = int(math.floor(self.milliseconds()) / 1000) defaultExpirationDays = self.safe_integer(self.options, 'defaultExpirationDays', 30) expiration = nowSeconds + (defaultExpirationDays * 24 * 60 * 60) # Get nonce(default: current timestamp in seconds) nonce = self.safe_integer(params, 'nonce') if nonce is None: nonce = 0 # Default nonce is 0 # Get signer address(default: maker address) signer = self.safe_string(params, 'signer') if signer is None: signer = self.get_main_wallet_address() normalizedSigner = self.normalize_address(signer) # Generate salt(unique integer based on microseconds) # Using microseconds for better uniqueness without relying on Math.random() salt = int(math.floor(self.milliseconds()) / 1000) # Calculate makerAmount and takerAmount from size and price # Key steps: 1) Round down size first, 2) Calculate other amount, 3) Round if needed, 4) Convert to smallest units # Get precision from market info or use defaults(USDC: 6 decimals, Tokens: 18 decimals) orderMarketInfo: Any = {} if market is not None: orderMarketInfo = self.safe_dict(market, 'info', {}) marketPrecision: Any = {} if market is not None: marketPrecision = self.safe_dict(market, 'precision', {}) quoteDecimals = self.safe_integer(orderMarketInfo, 'quoteDecimals', self.safe_integer(marketPrecision, 'price')) baseDecimals = self.safe_integer(orderMarketInfo, 'baseDecimals', self.safe_integer(marketPrecision, 'amount')) defaultTickSize = self.safe_string(self.options, 'defaultTickSize') tickSize = self.safe_string(orderMarketInfo, 'tick_size', defaultTickSize) roundingConfig = self.get_rounding_config(tickSize) priceDecimals = self.safe_integer(roundingConfig, 'price', 2) sizeDecimals = self.safe_integer(roundingConfig, 'size', 2) amountDecimals = self.safe_integer(roundingConfig, 'amount', 4) makerAmount: str takerAmount: str isBuy = (side.upper() == 'BUY') # Get price: from parameter, or from params.marketPrice for market orders orderPrice = price if orderPrice is None: orderPrice = self.safe_string(params, 'marketPrice') if orderPrice is None: raise ArgumentsRequired(self.id + ' buildAndSignOrder() requires a price parameter or params.marketPrice') # Round price and size first, then calculate amounts(same logic for limit and market orders) rawPrice = self.round_normal(orderPrice, priceDecimals) # Check if self is a market order for special decimal handling orderType = self.safe_string(params, 'orderType', 'limit') isMarketOrder = (orderType == 'market') # Get rounding buffer constant roundingBuffer = self.safe_integer(self.options, 'roundingBufferDecimals', 4) # Determine decimal precision based on order type and side makerDecimals = 0 takerDecimals = 0 if isMarketOrder: # Get market order decimal limits for quote(USDC) and base(tokens) marketOrderQuoteDecimals = self.safe_integer(self.options, 'marketOrderQuoteDecimals', 2) marketOrderBaseDecimals = self.safe_integer(self.options, 'marketOrderBaseDecimals', 4) if isBuy: # Market buy orders: maker gives USDC(quote), taker gives tokens(base) makerDecimals = marketOrderQuoteDecimals takerDecimals = marketOrderBaseDecimals else: # Market sell orders: maker gives tokens(base), taker gives USDC(quote) makerDecimals = marketOrderBaseDecimals takerDecimals = marketOrderQuoteDecimals else: # Limit orders: use amountDecimals for both makerDecimals = amountDecimals takerDecimals = amountDecimals if isBuy: # BUY: maker gives USDC, wants tokens # Round down size first rawTakerAmt = self.round_down(size, sizeDecimals) # Round taker amount to max decimals if self.decimal_places(rawTakerAmt) > takerDecimals: rawTakerAmt = self.round_down(rawTakerAmt, takerDecimals) # Calculate maker amount: raw_maker_amt = raw_taker_amt * raw_price # Do NOT round calculated amounts - preserve full precision for accurate calculations # The decimal limits apply to input size and final representation, not intermediate calculations rawMakerAmt = Precise.string_mul(rawTakerAmt, rawPrice) # Convert to smallest units: maker gives USDC(quoteDecimals), taker gives tokens(baseDecimals) makerAmount = self.to_token_decimals(rawMakerAmt, quoteDecimals) takerAmount = self.to_token_decimals(rawTakerAmt, baseDecimals) else: # SELL: maker gives tokens, wants USDC # Round down size first rawMakerAmt = self.round_down(size, sizeDecimals) # Round maker amount to max decimals if self.decimal_places(rawMakerAmt) > makerDecimals: rawMakerAmt = self.round_down(rawMakerAmt, makerDecimals) # Calculate taker amount: raw_taker_amt = raw_maker_amt * raw_price # Do NOT round calculated amounts - preserve full precision for accurate calculations # The decimal limits apply to input size and final representation, not intermediate calculations rawTakerAmt = Precise.string_mul(rawMakerAmt, rawPrice) # Convert to smallest units: maker gives tokens(baseDecimals), taker gives USDC(quoteDecimals) makerAmount = self.to_token_decimals(rawMakerAmt, baseDecimals) takerAmount = self.to_token_decimals(rawTakerAmt, quoteDecimals) sideInt = self.get_side(side, params) order: dict = { 'salt': str(salt), # uint256 'maker': normalizedMaker, # address 'signer': normalizedSigner, # address 'taker': normalizedTaker, # address 'tokenId': str(tokenId), # uint256 'makerAmount': str(makerAmount), # uint256 'takerAmount': str(takerAmount), # uint256 'expiration': str(expiration), # uint256 'nonce': str(nonce), # uint256 'feeRateBps': str(feeRateBps), # uint256 'side': sideInt, # uint8: number(0 or 1) 'signatureType': signatureType, # uint8: number(0, 1, or 2) } chainId = self.safe_integer(self.options, 'chainId') orderDomainName = self.safe_string(self.options, 'orderDomainName') orderDomainVersion = self.safe_string(self.options, 'orderDomainVersion') contractConfig = self.get_contract_config(chainId) verifyingContract = self.normalize_address(self.safe_string(contractConfig, 'exchange')) # Domain must match exactly what server expects for signature validation domain = { 'name': orderDomainName, 'version': orderDomainVersion, 'chainId': chainId, 'verifyingContract': verifyingContract, } # EIP-712 types for orders from https://github.com/Polymarket/clob-order-utils/blob/main/src/exchange.order.const.ts ORDER_STRUCTURE = [ {'name': 'salt', 'type': 'uint256'}, {'name': 'maker', 'type': 'address'}, {'name': 'signer', 'type': 'address'}, {'name': 'taker', 'type': 'address'}, {'name': 'tokenId', 'type': 'uint256'}, {'name': 'makerAmount', 'type': 'uint256'}, {'name': 'takerAmount', 'type': 'uint256'}, {'name': 'expiration', 'type': 'uint256'}, {'name': 'nonce', 'type': 'uint256'}, {'name': 'feeRateBps', 'type': 'uint256'}, {'name': 'side', 'type': 'uint8'}, {'name': 'signatureType', 'type': 'uint8'}, ] # primary type is types[0] => 'primaryType': 'Order' # EIP712Domain shouldn't be included in messageTypes messageTypes: dict = { 'Order': ORDER_STRUCTURE, } signature = self.sign_typed_data(domain, messageTypes, order) order['signature'] = signature return order async def build_order(self, symbol: str, type: OrderType, side: OrderSide, amount: float, price: Num = None, params={}) -> dict: """ build a signed order request payload from order parameters https://docs.polymarket.com/developers/CLOB/orders/create-order https://docs.polymarket.com/developers/CLOB/orders/create-order-batch :param str symbol: unified symbol of the market to create an order in :param str type: 'market' or 'limit' :param str side: 'buy' or 'sell' :param float amount: how much you want to trade in units of the base currency :param float [price]: the price at which the order is to be fulfilled, in units of the quote currency, ignored in market orders :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required if market has multiple outcomes) :param str [params.timeInForce]: 'GTC', 'IOC', 'FOK', 'GTD'(default: 'GTC') :param str [params.clientOrderId]: a unique id for the order :param boolean [params.postOnly]: if True, the order will only be posted to the order book and not executed immediately :param number [params.expiration]: expiration timestamp in seconds(default: 30 days from now) :returns dict: request payload with order, owner, orderType, and optional fields """ market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' buildOrder() requires a token_id parameter for market ' + symbol) # Convert CCXT side to Polymarket side(BUY/SELL) polymarketSide = 'BUY' if (side == 'buy') else 'SELL' # Convert amount and price to strings size = self.number_to_string(amount) priceStr = None if type == 'limit': if price is None: raise ArgumentsRequired(self.id + ' buildOrder() requires a price parameter for limit orders') priceStr = self.number_to_string(price) elif type == 'market': # For market orders, price is optional but recommended # If not provided, we'll try to fetch from orderbook or use params.marketPrice if price is not None: priceStr = self.number_to_string(price) else: # Try to get price from params.marketPrice marketPrice = self.safe_number(params, 'marketPrice') if marketPrice is not None: priceStr = self.number_to_string(marketPrice) # Determine orderType(at top level, not inside order object) # Must be determined before building orderObject to set expiration correctly orderType = self.safe_string(params, 'timeInForce', 'GTC') if type == 'market': # For market orders, use IOC(Immediate-Or-Cancel) by default # IOC allows partial fills, making it more forgiving than FOK(Fill-Or-Kill) # Users can still override with params.timeInForce = 'FOK' if needed orderType = self.safe_string(params, 'timeInForce', 'IOC') # Set expiration BEFORE signing: for non-GTD orders(GTC, FOK, FAK), expiration must be '0' # Only GTD orders should have a timestamp expiration # The signature must match the exact expiration value that will be sent to the API # See https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters orderTypeUpper = orderType.upper() # For non-GTD orders, expiration MUST be '0'(API requirement) # Override any user-provided expiration for non-GTD orders orderParams = self.extend({}, params) if orderTypeUpper == 'GTD': expiration = self.safe_integer(params, 'expiration') if expiration is None: nowSeconds = int(math.floor(self.milliseconds()) / 1000) defaultExpirationDays = self.safe_integer(self.options, 'defaultExpirationDays', 30) expiration = nowSeconds + (defaultExpirationDays * 24 * 60 * 60) else: orderParams['expiration'] = str(expiration) else: # For non-GTD orders, expiration must be 0(will be converted to "0" string in signing) orderParams['expiration'] = 0 # Pass order type to buildAndSignOrder for market order special handling orderParams['orderType'] = type # Build and sign the order with EIP-712(pass market to use fees from market) signedOrder = await self.build_and_sign_order(tokenId, polymarketSide, size, priceStr, market, orderParams) # override signedOrder types signedOrder['salt'] = self.parse_to_int(signedOrder['salt']) # integer not string signedOrder['side'] = polymarketSide # string(BUY or SELL) # Get API credentials for owner field apiCredentials = self.get_api_credentials() owner = self.safe_string(apiCredentials, 'apiKey') if owner is None: raise AuthenticationError(self.id + ' buildOrder() requires API credentials(apiKey)') # Build request payload according to API specification # Top-level fields: order, owner, orderType requestPayload: dict = { 'order': signedOrder, 'owner': owner, 'orderType': orderType.upper(), } # Add optional parameters if provided clientOrderId = self.safe_string(params, 'clientOrderId') if clientOrderId is not None: requestPayload['clientOrderId'] = clientOrderId postOnly = self.safe_bool(params, 'postOnly', False) if postOnly: requestPayload['postOnly'] = True return requestPayload async def create_order(self, symbol: str, type: OrderType, side: OrderSide, amount: float, price: Num = None, params={}) -> Order: """ create a trade order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/create-order https://github.com/Polymarket/clob-order-utils https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters :param str symbol: unified symbol of the market to create an order in :param str type: 'market' or 'limit' :param str side: 'buy' or 'sell' :param float amount: how much you want to trade in units of the base currency :param float [price]: the price at which the order is to be fulfilled, in units of the quote currency, ignored in market orders :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required if market has multiple outcomes) :param str [params.timeInForce]: 'GTC', 'IOC', 'FOK', 'GTD'(default: 'GTC') :param str [params.clientOrderId]: a unique id for the order :param boolean [params.postOnly]: if True, the order will only be posted to the order book and not executed immediately :param number [params.expiration]: expiration timestamp in seconds(default: 30 days from now) :param number [params.nonce]: order nonce(default: current timestamp) :param number [params.feeRateBps]: fee rate in basis points(default: fetched from API) :returns dict: an `order structure ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Build the order request payload requestPayload = await self.build_order(symbol, type, side, amount, price, params) # Extract clientOrderId from request payload for return value clientOrderId = self.safe_string(requestPayload, 'clientOrderId') # Submit order via POST /order endpoint response = await self.clob_private_post_order(self.extend(requestPayload, params)) # Response format: # { # "success": boolean, # "errorMsg": string(if error), # "orderId": string, # "orderHashes": string[](if order was marketable) # } success = self.safe_bool(response, 'success', True) if not success: errorMsg = self.safe_string(response, 'errorMsg', 'Unknown error') raise ExchangeError(self.id + ' createOrder() failed: ' + errorMsg) orderId = self.safe_string(response, 'orderID') if orderId is None: raise ExchangeError(self.id + ' createOrder() response missing orderID') market = None if symbol: market = self.market(symbol) # Combine response with order details from requestPayload for parseOrder orderData = self.extend({ 'orderID': orderId, 'clientOrderId': clientOrderId, 'order': requestPayload['order'], # Include the signed order for additional context 'order_type': requestPayload['orderType'], # Include orderType for parseOrder }, response) order = self.parse_order(orderData, market) return order async def create_orders(self, orders: List[OrderRequest], params={}) -> List[Order]: """ create multiple trade orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/create-order-batch :param Array orders: list of orders to create, each order should contain the parameters required by createOrder :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: an array of `order structures ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) orderRequests = [] clientOrderIds = [] symbols = [] for i in range(0, len(orders)): order = orders[i] symbol = self.safe_string(order, 'symbol') if symbol is None: raise ArgumentsRequired(self.id + ' createOrders() requires a symbol in each order') type = self.safe_string(order, 'type') side = self.safe_string(order, 'side') amount = self.safe_number(order, 'amount') price = self.safe_number(order, 'price') orderParams = self.safe_dict(order, 'params', {}) # Merge order-level params with top-level params mergedParams = self.extend({}, params, orderParams) # Get token_id from order params, order directly, or it will be resolved in buildOrder tokenId = self.safe_string(orderParams, 'token_id') or self.safe_string(order, 'token_id') if tokenId is not None: mergedParams['token_id'] = tokenId # Get clientOrderId from order params or order directly clientOrderId = self.safe_string(orderParams, 'clientOrderId') or self.safe_string(order, 'clientOrderId') if clientOrderId is not None: mergedParams['clientOrderId'] = clientOrderId # Get timeInForce from order params or order directly timeInForce = self.safe_string(orderParams, 'timeInForce') or self.safe_string(order, 'timeInForce') if timeInForce is not None: mergedParams['timeInForce'] = timeInForce # Build the order request payload using the shared buildOrder function orderRequest = await self.build_order(symbol, type, side, amount, price, mergedParams) # Store clientOrderId from request payload for response parsing requestClientOrderId = self.safe_string(orderRequest, 'clientOrderId') clientOrderIds.append(requestClientOrderId) symbols.append(symbol) orderRequests.append(orderRequest) # Submit batch orders via POST /orders endpoint response = await self.clob_private_post_orders(self.extend({'orders': orderRequests}, params)) # Response format: array of order responses, each with: # { # "success": boolean, # "errorMsg": string(if error), # "orderId": string, # "orderHashes": string[](if order was marketable) # } result = [] for i in range(0, len(response)): orderResponse = response[i] success = self.safe_bool(orderResponse, 'success', True) if not success: errorMsg = self.safe_string(orderResponse, 'errorMsg', 'Unknown error') raise ExchangeError(self.id + ' createOrders() failed for order ' + i + ': ' + errorMsg) orderId = self.safe_string(orderResponse, 'orderID') if orderId is None: raise ExchangeError(self.id + ' createOrders() response missing orderID for order ' + i) market = None if symbols[i]: market = self.market(symbols[i]) # Combine response with order details from orderRequests for parseOrder orderData = self.extend({ 'orderID': orderId, 'clientOrderId': clientOrderIds[i], 'order': orderRequests[i]['order'], # Include the signed order for additional context 'order_type': orderRequests[i]['orderType'], # Include orderType for parseOrder }, orderResponse) result.append(self.parse_order(orderData, market)) return result async def create_market_order(self, symbol: str, side: OrderSide, amount: float, price: Num = None, params={}): """ create a market order :param str symbol: unified symbol of the market to create an order in :param str side: 'buy' or 'sell' :param float amount: how much you want to trade in units of the base currency :param float [price]: ignored for market orders :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: an `order structure ` """ # Use IOC by default for market orders(allows partial fills) # Users can override with params.timeInForce = 'FOK' if they need Fill-Or-Kill behavior return await self.create_order(symbol, 'market', side, amount, price, self.extend(params, {'timeInForce': 'IOC'})) async def cancel_order(self, id: str, symbol: Str = None, params={}) -> Order: """ cancels an open order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/cancel-order :param str id: order id :param str symbol: unified symbol of the market the order was made in :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: An `order structure ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Based on cancel() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL = "/order") # Response format: {canceled: string[], not_canceled: {order_id -> reason}} # See https://docs.polymarket.com/developers/CLOB/orders/cancel-order response = await self.clob_private_delete_order(self.extend({'order_id': id}, params)) canceled = self.safe_list(response, 'canceled', []) notCanceled = self.safe_dict(response, 'not_canceled', {}) # Check if order was successfully canceled isCanceled = False for i in range(0, len(canceled)): if canceled[i] == id: isCanceled = True break if isCanceled: # Order was canceled, parse order from response data market = self.market(symbol) if symbol else None orderData = { 'id': id, 'status': 'canceled', 'info': response, } return self.parse_order(orderData, market) else: # Check if order is in not_canceled map reason = self.safe_string(notCanceled, id) if reason is not None: # Order couldn't be canceled, raise error with reason raise ExchangeError(self.id + ' cancelOrder() failed: ' + reason) else: # Order ID not found in response(shouldn't happen) raise ExchangeError(self.id + ' cancelOrder() unexpected response format') async def cancel_orders(self, ids: List[str], symbol: Str = None, params={}) -> List[Order]: """ cancel multiple orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/cancel-order-batch :param str[] ids: order ids :param str symbol: unified symbol of the market the orders were made in :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: an array of `order structures ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Based on cancel_orders() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ORDERS = "/orders") # Response format: {canceled: string[], not_canceled: {order_id -> reason}} # See https://docs.polymarket.com/developers/CLOB/orders/cancel-order-batch response = await self.clob_private_delete_orders(self.extend({'order_ids': ids}, params)) canceled = self.safe_list(response, 'canceled', []) notCanceled = self.safe_dict(response, 'not_canceled', {}) market = self.market(symbol) if symbol else None orders: List[Order] = [] # Add canceled orders for i in range(0, len(canceled)): orderId = canceled[i] orderData = { 'id': orderId, 'status': 'canceled', 'info': response, } orders.append(self.parse_order(orderData, market)) # Verify all requested orders are accounted for in the response for i in range(0, len(ids)): orderId = ids[i] isInCanceled = False for j in range(0, len(canceled)): if canceled[j] == orderId: isInCanceled = True break if not isInCanceled and not (orderId in notCanceled): # Order ID not found in response(unexpected) raise ExchangeError(self.id + ' cancelOrders() unexpected response format for order ' + orderId) return orders async def cancel_all_orders(self, symbol: Str = None, params={}) -> List[Order]: """ cancel all open orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/cancel-all-orders :param str [symbol]: unified market symbol, only orders in the market of self symbol are cancelled when symbol is not None :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `order structures ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) response if symbol is not None: # Use cancel-market-orders endpoint when symbol is provided # See https://docs.polymarket.com/developers/CLOB/orders/cancel-market-orders market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get condition_id(market ID) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) # Get asset_id from clobTokenIds clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) request: dict = {} if conditionId is not None: request['market'] = conditionId if len(clobTokenIds) > 0: request['asset_id'] = clobTokenIds[0] # Response format: {canceled: string[], not_canceled: {order_id -> reason}} response = await self.clob_private_delete_cancel_market_orders(self.extend(request, params)) else: # Use cancel-all endpoint when symbol is None # Based on cancel_all() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ALL = "/cancel-all") # Response format: {canceled: string[], not_canceled: {order_id -> reason}} # See https://docs.polymarket.com/developers/CLOB/orders/cancel-all-orders response = await self.clob_private_delete_cancel_all(params) canceled = self.safe_list(response, 'canceled', []) orderMarket = self.market(symbol) if symbol else None orders: List[Order] = [] # Add canceled orders for i in range(0, len(canceled)): orderId = canceled[i] orderData = { 'id': orderId, 'status': 'canceled', 'info': response, } orders.append(self.parse_order(orderData, orderMarket)) return orders async def fetch_order(self, id: str, symbol: Str = None, params={}) -> Order: """ fetches information on an order made by the user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/get-order :param str id: order id :param str symbol: unified symbol of the market the order was made in :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: An `order structure ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Based on get_order() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_ORDER = "/data/order/") response = await self.clob_private_get_order(self.extend({'order_id': id}, params)) market = self.market(symbol) if symbol else None return self.parse_order(response, market) async def fetch_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Order]: """ fetches information on multiple orders made by the user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/get-orders :param str symbol: unified symbol of the market the orders were made in :param int [since]: the earliest time in ms to fetch orders for :param int [limit]: the maximum number of order structures to retrieve :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: filter orders by order id :param str [params.market]: filter orders by market id :param str [params.asset_id]: filter orders by asset id(alias token_id) :returns dict[]: a list of `order structures ` """ await self.load_markets() await self.ensure_api_credentials(params) request = {} if symbol is not None: market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Filter by condition_id(market) to get all orders for self market # This is more appropriate than filtering by asset_id alone, market can have multiple outcomes conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) if conditionId is not None: request['market'] = conditionId # Also include asset_id for backward compatibility and more specific filtering clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # The Polymarket L2 getOpenOrders() endpoint filters by asset_id request['asset_id'] = clobTokenIds[0] # Keep backward compatibility for legacy token_id usage request['token_id'] = clobTokenIds[0] id = self.safe_string(params, 'id') if id is not None: request['id'] = id marketId = self.safe_string(params, 'market') if marketId is not None: request['market'] = marketId assetId = self.safe_string_2(params, 'asset_id', 'token_id') if assetId is not None: request['asset_id'] = assetId request['token_id'] = assetId initialCursor = self.safe_string(self.options, 'initialCursor') endCursor = self.safe_string(self.options, 'endCursor') nextCursor = initialCursor ordersResponse: List[Any] = [] while(True): response = await self.clob_private_get_orders(self.extend(request, {'next_cursor': nextCursor}, params)) data = self.safe_list(response, 'data', []) ordersResponse = self.array_concat(ordersResponse, data) if limit is not None and len(ordersResponse) >= limit: break nextCursor = self.safe_string(response, 'next_cursor') if nextCursor is None or nextCursor == endCursor: break orderMarket = self.market(symbol) if symbol else None return self.parse_orders(ordersResponse, orderMarket, since, limit) async def fetch_open_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Order]: """ fetch all unfilled currently open orders :param str symbol: unified symbol of the market to fetch open orders for :param int [since]: the earliest time in ms to fetch open orders for :param int [limit]: the maximum number of open order structures to retrieve :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `order structures ` """ # The Polymarket getOpenOrders() endpoint already returns open orders return await self.fetch_orders(symbol, since, limit, params) def parse_order(self, order: dict, market: Market = None) -> Order: """ parses an order from the exchange response format :param dict order: order response from the exchange :param dict [market]: market structure :returns dict: an `order structure ` """ # Handle createOrder/createOrders response format: # { # "success": boolean, # "errorMsg": string(if error), # "orderID": string, # "orderHashes": string[](if order was marketable) # } # Or fetchOrder response format(OpenOrder interface): # { # id: string # status: string # owner: string # maker_address: string # market: string # asset_id: string # side: string # original_size: string # size_matched: string # price: string # associate_trades: string[] # outcome: string # created_at: number # seconds # expiration: string # order_type: string # } id = self.safe_string(order, 'id') # Handle createOrder response format(has orderID instead of id) if id is None: id = self.safe_string(order, 'orderID') marketId = self.safe_string(order, 'market') assetId = self.safe_string(order, 'asset_id') if market is None and marketId is not None: market = self.safe_market(marketId, None) symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif assetId is not None: symbol = assetId # Handle createOrder response - get side from order object if available sideStr = self.safe_string_lower(order, 'side') # If side is not in order, try to get it from the order object passed in createOrder if sideStr is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: sideStr = self.safe_string_lower(orderObj, 'side') side = sideStr if (sideStr == 'buy' or sideStr == 'sell') else None orderType = self.safe_string(order, 'order_type') # Handle createOrder response - get orderType from order object if available if orderType is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: orderType = self.safe_string(orderObj, 'orderType') # Also check at top level(from requestPayload) if orderType is None: orderType = self.safe_string(order, 'orderType') # Normalize orderType to lowercase for consistent parsing if orderType is not None: orderType = orderType.lower() # Amounts amount = self.safe_number(order, 'original_size') # Handle createOrder response - get amount from order object if available if amount is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: amount = self.safe_number(orderObj, 'size') filled = self.safe_number(order, 'size_matched') remaining = self.safe_number(order, 'remaining_size') if remaining is None and amount is not None and filled is not None: remaining = amount - filled # Price price = self.safe_number(order, 'price') # Handle createOrder response - get price from order object if available if price is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: price = self.safe_number(orderObj, 'price') # Status statusStr = self.safe_string(order, 'status', '') status = self.parse_order_status(statusStr) # Timestamps(created_at is seconds) createdAt = self.safe_integer(order, 'created_at') timestamp = createdAt * 1000 if (createdAt is not None) else None # Get clientOrderId from order or from the order object clientOrderId = self.safe_string(order, 'clientOrderId') if clientOrderId is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: clientOrderId = self.safe_string(orderObj, 'clientOrderId') # No explicit updated_at in interface; leave lastTradeTimestamp None return self.safe_order({ 'id': id, 'clientOrderId': clientOrderId, 'info': order, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp) if timestamp else None, 'lastTradeTimestamp': None, 'status': status, 'symbol': symbol, 'type': self.parse_order_type(orderType), 'timeInForce': self.parse_time_in_force(orderType), 'side': side, 'price': price, 'amount': amount, 'cost': None, 'average': None, 'filled': filled, 'remaining': remaining, 'fee': None, }, market) def parse_order_status(self, status: Str) -> Str: """ parse the status of an order :param str status: order status from exchange :returns str: a unified order status """ if status is None or status == '': return 'open' # Default to 'open' if no status is provided statuses: dict = { # https://docs.polymarket.com/developers/CLOB/orders/create-order#status 'matched': 'closed', # order placed and matched with an existing resting order 'live': 'open', # order placed and resting on the book 'delayed': 'open', # order marketable, but subject to matching delay 'unmatched': 'open', # order marketable, but failure delaying, placement successful 'canceled': 'canceled', # CCXT unified status for canceled orders } normalizedStatus = status.lower() return self.safe_string(statuses, normalizedStatus, normalizedStatus) def parse_order_type(self, type: Str) -> Str: types: dict = { 'fok': 'market', # Fill-Or-Kill: market order 'fak': 'market', # Fill-And-Kill: market order 'ioc': 'market', # Immediate-Or-Cancel: market order 'gtc': 'limit', # Good-Til-Cancelled: limit order 'gtd': 'limit', # Good-Til-Date: limit order } return self.safe_string(types, type, 'limit') def parse_time_in_force(self, timeInForce: Str) -> Str: if timeInForce is None: return None timeInForces: dict = { 'fok': 'FOK', # Fill-Or-Kill 'fak': 'FAK', # Fill-And-Kill 'ioc': 'IOC', # Immediate-Or-Cancel 'gtc': 'GTC', # Good-Til-Cancelled 'gtd': 'GTD', # Good-Til-Date } normalized = timeInForce.lower() mapped = self.safe_string(timeInForces, normalized) return mapped is not mapped if None else timeInForce.upper() async def fetch_time(self, params={}) -> Int: """ fetches the current integer timestamp in milliseconds from the exchange server https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L178 :param dict [params]: extra parameters specific to the exchange API endpoint :returns int: the current integer timestamp in milliseconds from the exchange server """ # Based on get_server_time() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L178 response = await self.clob_public_get_time(params) # Response format: timestamp in seconds(Unix timestamp) # Convert to milliseconds for CCXT standard timestamp = self.safe_integer(response, 'timestamp') if timestamp is not None: return timestamp * 1000 # Convert seconds to milliseconds # Fallback: if response is just a number if isinstance(response, numbers.Real): return response * 1000 # Fallback: use current time if server time not available return self.milliseconds() async def fetch_status(self, params={}): """ the latest known information on the availability of the exchange API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: a `status structure ` """ # Based on get_ok() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 try: await self.clob_public_get_ok(params) return { 'status': 'ok', 'updated': None, 'eta': None, 'url': None, } except Exception as e: return { 'status': 'error', 'updated': None, 'eta': None, 'url': None, } async def fetch_trading_fee(self, symbol: str, params={}) -> TradingFeeInterface: """ fetches the trading fee for a market https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param str symbol: unified symbol of the market to fetch the fee for :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required if not in market info) :returns dict: a `fee structure ` """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchTradingFee() requires a token_id parameter for market ' + symbol) # Based on get_fee_rate() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = await self.clob_public_get_fee_rate(self.extend({'token_id': tokenId}, params)) # Response format: {"fee_rate": "0.02"} or {"fee_rate_bps": 200}(basis points) feeRate = self.safe_string(response, 'fee_rate') feeRateBps = self.safe_integer(response, 'fee_rate_bps') maker: Num = None taker: Num = None if feeRate is not None: fee = self.parse_number(feeRate) maker = fee taker = fee elif feeRateBps is not None: # Convert basis points to percentage(200 bps = 2% = 0.02) fee = self.parse_number(feeRateBps) / 10000 maker = fee taker = fee else: # Default fee from describe() if not available maker = self.safe_number(self.fees['trading'], 'maker') taker = self.safe_number(self.fees['trading'], 'taker') # Ensure we have valid numbers(fallback to default if None) makerFee: Num = maker is not maker if None else self.parse_number('0.02') takerFee: Num = taker is not taker if None else self.parse_number('0.02') result: TradingFeeInterface = { 'info': response, 'symbol': symbol, 'maker': makerFee, 'taker': takerFee, 'percentage': True, 'tierBased': False, } return result async def fetch_open_interest(self, symbol: str, params={}): """ retrieves the open interest of a market https://docs.polymarket.com/api-reference/misc/get-open-interest :param str symbol: unified CCXT market symbol :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict} an open interest structure{@link https://docs.ccxt.com/#/?id=open-interest-structure: """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) # API expects market of condition IDs request: dict = { 'market': [conditionId], } response = await self.data_public_get_open_interest(self.extend(request, params)) return self.parse_open_interest(response, market) def parse_open_interest(self, interest: dict, market: Market = None): """ parses open interest data from the exchange response format :param dict interest: open interest data from the exchange :param dict [market]: the market self open interest is for :returns dict} an open interest structure{@link https://docs.ccxt.com/#/?id=open-interest-structure: """ # Polymarket Data API /oi response format # Response is an array of objects with market(condition ID) and value # Example response structure: # [ # { # "market": "0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917", # "value": 123 # } # ] timestamp = self.milliseconds() # Handle array response openInterestData: dict = {} if isinstance(interest, list): # For single symbol query, get the first item if len(interest) > 0: openInterestData = interest[0] elif isinstance(interest, dict) and interest != None: # Fallback: handle object response if API changes openInterestData = interest # Extract open interest value from the response # API returns "value" field which represents the open interest value openInterestValue = self.safe_number(openInterestData, 'value', 0) # For Polymarket, value is typically in USDC, so we use it amount and value # If we need to distinguish, we could parse additional fields if available return self.safe_open_interest({ 'symbol': market['symbol'] if market else None, 'openInterestAmount': openInterestValue, # Using value since API only provides value 'openInterestValue': openInterestValue, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp), 'info': interest, }, market) async def fetch_my_trades(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ fetch all trades made by the user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param str symbol: unified symbol of the market to fetch trades for :param int [since]: the earliest time in ms to fetch trades for :param int [limit]: the maximum number of trades structures to retrieve :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: filter trades by market(condition_id) :param str [params.asset_id]: filter trades by asset ID :param str [params.id]: filter by trade id :param str [params.maker_address]: filter by maker address :param str [params.before]: pagination cursor(see API docs) :param str [params.after]: pagination cursor(see API docs) :param str [params.next_cursor]: pagination cursor(default: "MA==") :returns Trade[]: a list of `trade structures ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) request: dict = {} market = None if symbol is not None: market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Filter by condition_id(market) to get all trades for self market # Don't automatically add asset_id filter would restrict to only one outcome conditionId = self.safe_string(marketInfo, 'condition_id', self.safe_string(market, 'id')) if conditionId is not None: request['market'] = conditionId # Backward compatibility: token_id alias to asset_id tokenId = self.safe_string(params, 'token_id') if tokenId is not None: request['asset_id'] = tokenId marketId = self.safe_string(params, 'market') if marketId is not None: request['market'] = marketId assetId = self.safe_string_2(params, 'asset_id', 'assetId') if assetId is not None: request['asset_id'] = assetId id = self.safe_string(params, 'id') if id is not None: request['id'] = id makerAddress = self.safe_string_2(params, 'maker_address', 'makerAddress') if makerAddress is not None: request['maker_address'] = makerAddress before = self.safe_string(params, 'before') if before is not None: request['before'] = before after = self.safe_string(params, 'after') if after is not None: request['after'] = after if since is not None: # Map ccxt since to Polymarket's "after" cursor using seconds request['after'] = self.number_to_string(int(math.floor(since / 1000))) if limit is not None: request['limit'] = limit results: List[Any] = [] initialCursor = self.safe_string(self.options, 'initialCursor') endCursor = self.safe_string(self.options, 'endCursor') next_cursor = initialCursor while(next_cursor != endCursor): response = await self.clob_private_get_trades(self.extend(request, {'next_cursor': next_cursor}, params)) next_cursor = self.safe_string(response, 'next_cursor', endCursor) data = self.safe_list(response, 'data', []) or [] results = self.array_concat(results, data) if limit is not None and len(results) >= limit: break return self.parse_trades(results, market, since, limit) async def fetch_user_trades(self, user: str, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ fetch trades for a specific user https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets :param str user: user address(0x-prefixed, 40 hex chars) :param str [symbol]: unified symbol of the market to fetch trades for :param int [since]: timestamp in ms of the earliest trade to fetch :param int [limit]: the maximum amount of trades to fetch(default: 100, max: 10000) :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.offset]: offset for pagination(default: 0, max: 10000) :param boolean [params.takerOnly]: if True, returns only trades where the user is the taker(default: True) :param str [params.side]: filter by side: 'BUY' or 'SELL' :param str[] [params.market]: comma-separated list of condition IDs(mutually exclusive with symbol) :param int[] [params.eventId]: comma-separated list of event IDs(mutually exclusive with market) :returns Trade[]: a list of `trade structures ` """ await self.load_markets() request: dict = { 'user': user, } market = None if symbol is not None: market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) request['market'] = [conditionId] marketParam = self.safe_value(params, 'market') if marketParam is not None: # Convert to array if it's a string or single value if isinstance(marketParam, list): request['market'] = marketParam else: request['market'] = [marketParam] eventId = self.safe_value(params, 'eventId') if eventId is not None: if isinstance(eventId, list): request['eventId'] = eventId else: request['eventId'] = [eventId] if limit is not None: request['limit'] = min(limit, 10000) # Cap at max 10000 offset = self.safe_integer(params, 'offset') if offset is not None: request['offset'] = offset takerOnly = self.safe_bool(params, 'takerOnly', True) request['takerOnly'] = takerOnly side = self.safe_string_upper(params, 'side') if side is not None: request['side'] = side response = await self.data_public_get_trades(self.extend(request, self.omit(params, ['market', 'eventId', 'offset', 'takerOnly', 'side']))) tradesData = [] if isinstance(response, list): tradesData = response else: dataList = self.safe_list(response, 'data', []) if dataList is not None: tradesData = dataList return self.parse_trades(tradesData, market, since, limit) async def fetch_balance(self, params={}): """ fetches balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/clients/methods-l2#getbalanceallowance :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_type]: asset type: 'COLLATERAL'(default) or 'CONDITIONAL' :param str [params.token_id]: token ID, default: from options.defaultTokenId) :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: a `balance structure ` """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Default asset_type to COLLATERAL if not provided assetType = self.safe_string(params, 'asset_type', 'COLLATERAL') params['asset_type'] = assetType # Use signature_type from params or fall back to options signatureType = self.get_signature_type(params) request: dict = { 'asset_type': assetType, } if signatureType is not None: request['signature_type'] = signatureType tokenId = self.safe_string(params, 'token_id') if tokenId is None: defaultTokenId = self.safe_string(self.options, 'defaultTokenId') if defaultTokenId is not None: request['token_id'] = defaultTokenId else: request['token_id'] = tokenId # Fetch balance and allowance from CLOB endpoint clobResponse = await self.clob_private_get_balance_allowance(request) # # { # "balance": "1000000", # "allowance": "0" # } # balance = self.safe_string(clobResponse, 'balance') allowance = self.safe_string(clobResponse, 'allowance') collateral = self.safe_string(self.options, 'defaultCollateral', 'USDC') # Convert CLOB balance and allowance(6 decimals) to standard units collateralTotalValue = None collateralUsedValue = None collateralFreeValue = None if balance is not None: parsedBalance = self.parse_number(balance) if parsedBalance is not None: collateralTotalValue = parsedBalance / 1000000 if allowance is not None: parsedAllowance = self.parse_number(allowance) if parsedAllowance is not None: collateralUsedValue = parsedAllowance / 1000000 # Calculate free balance: total - used(allowance) if collateralTotalValue is not None and collateralUsedValue is not None: collateralFreeValue = collateralTotalValue - collateralUsedValue elif collateralTotalValue is not None: collateralFreeValue = collateralTotalValue result: dict = { 'info': clobResponse, } if collateralTotalValue is not None: account = self.account() account['total'] = collateralTotalValue if collateralFreeValue is not None: account['free'] = collateralFreeValue if collateralUsedValue is not None: account['used'] = collateralUsedValue result[collateral] = account return self.safe_balance(result) async def get_notifications(self, params={}): """ fetches notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Use signature_type from params or fall back to options signatureType = self.get_signature_type(params) request: dict = {} if signatureType is not None: request['signature_type'] = signatureType # Based on get_notifications() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = await self.clob_private_get_notifications(self.extend(request, params)) return response async def drop_notifications(self, params={}): """ drops notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.notification_id]: specific notification ID to drop(optional) :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Use signature_type from params or fall back to options signatureType = self.get_signature_type(params) request: dict = {} if signatureType is not None: request['signature_type'] = signatureType # Based on drop_notifications() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = await self.clob_private_delete_notifications(self.extend(request, params)) return response async def get_balance_allowance(self, params={}): """ fetches balance and allowance for the authenticated user(alias for fetchBalance) https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/clients/methods-l2#getbalanceallowance :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_type]: asset type: 'COLLATERAL'(default) or 'CONDITIONAL' :param str [params.token_id]: token ID, default: from options.defaultTokenId) :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Alias for fetchBalance, but returns raw response # Use signature_type from params or fall back to options if self.safe_integer(params, 'signature_type') is None: signatureType = self.get_signature_type(params) if signatureType is not None: params['signature_type'] = signatureType # Default asset_type to COLLATERAL if not provided(for USDC balance) assetType = self.safe_string(params, 'asset_type', 'COLLATERAL') params['asset_type'] = assetType tokenId = self.safe_string(params, 'token_id') if tokenId is None: defaultTokenId = self.safe_string(self.options, 'defaultTokenId') if defaultTokenId is not None: params['token_id'] = defaultTokenId else: params['token_id'] = tokenId return await self.clob_private_get_balance_allowance(params) async def update_balance_allowance(self, params={}): """ updates balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) # Based on update_balance_allowance() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py # Use signature_type from params or fall back to options if self.safe_integer(params, 'signature_type') is None: signatureType = self.get_signature_type(params) if signatureType is not None: params['signature_type'] = signatureType response = await self.clob_private_put_balance_allowance(params) return response async def is_order_scoring(self, params={}): """ checks if an order is currently scoring https://docs.polymarket.com/developers/CLOB/orders/check-scoring#check-order-reward-scoring :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID to check(required) :returns dict: response from the exchange indicating if order is scoring """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) orderId = self.safe_string(params, 'order_id') if orderId is None: raise ArgumentsRequired(self.id + ' isOrderScoring() requires an order_id parameter') response = await self.clob_private_get_is_order_scoring(params) # Response: {scoring: boolean} return response async def are_orders_scoring(self, params={}): """ checks if multiple orders are currently scoring https://docs.polymarket.com/developers/CLOB/orders/check-scoring#check-order-reward-scoring :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.order_ids]: array of order IDs to check(required) :returns dict: response from the exchange indicating which orders are scoring """ await self.load_markets() # Ensure API credentials are generated(lazy generation) await self.ensure_api_credentials(params) orderIds = self.safe_value_2(params, 'order_ids', 'orderIds') if orderIds is None or not isinstance(orderIds, list): raise ArgumentsRequired(self.id + ' areOrdersScoring() requires an order_ids parameter(array of order IDs)') response = await self.clob_private_post_are_orders_scoring(self.extend({'orderIds': orderIds}, params)) # Response: {orderId: boolean, ...} return response async def clob_public_get_markets(self, params={}): """ fetches markets from CLOB API(matches clob-client getMarkets()) https://github.com/Polymarket/clob-client/blob/main/src/client.ts :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.next_cursor]: pagination cursor(default: options.initialCursor) :returns dict: response from the exchange """ # Pass api ['clob', 'public'] to match the expected format # The api parameter should be an array [api_type, access_level] for exchanges with multiple API types return await self.request('markets', ['clob', 'public'], 'GET', self.extend({'api_type': 'clob'}, params)) async def gamma_public_get_markets(self, params={}): """ fetches markets from Gamma API :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # Pass api ['gamma', 'public'] to match the expected format # The api parameter should be an array [api_type, access_level] for exchanges with multiple API types return await self.request('markets', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) async def gamma_public_get_markets_id(self, params={}): """ fetches a specific market by ID from Gamma API :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetMarketsId() requires an id parameter') path = 'markets/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def gamma_public_get_markets_id_tags(self, params={}): """ fetches tags for a specific market by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the market ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetMarketsIdTags() requires an id parameter') path = 'markets/' + self.encode_uri_component(id) + '/tags' remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def gamma_public_get_markets_slug_slug(self, params={}): """ fetches a specific market by slug from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.slug]: the market slug(required) :returns dict: response from the exchange """ slug = self.safe_string(params, 'slug') if slug is None: raise ArgumentsRequired(self.id + ' gammaPublicGetMarketsSlugSlug() requires a slug parameter') path = 'markets/slug/' + self.encode_uri_component(slug) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'slug')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def gamma_public_get_events(self, params={}): """ fetches events from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :param str [params.category]: filter by category :param str [params.slug]: filter by slug :returns dict: response from the exchange """ return await self.request('events', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) async def gamma_public_get_events_id(self, params={}): """ fetches a specific event by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the event ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetEventsId() requires an id parameter') path = 'events/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def gamma_public_get_series(self, params={}): """ fetches series from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :param str [params.category]: filter by category :param str [params.slug]: filter by slug :returns dict: response from the exchange """ return await self.request('series', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) async def gamma_public_get_series_id(self, params={}): """ fetches a specific series by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the series ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetSeriesId() requires an id parameter') path = 'series/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def gamma_public_get_search(self, params={}): """ performs a full-text search across events, tags, and user profiles from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.q]: search query(required) :param str [params.type]: filter by type: 'event', 'tag', 'user', etc. :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ q = self.safe_string(params, 'q') if q is None: raise ArgumentsRequired(self.id + ' gammaPublicGetSearch() requires a q(query) parameter') return await self.request('search', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) async def gamma_public_get_comments(self, params={}): """ fetches comments from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.event_id]: filter by event ID :param str [params.series_id]: filter by series ID :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ return await self.request('comments', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) async def gamma_public_get_comments_id(self, params={}): """ fetches a specific comment by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the comment ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetCommentsId() requires an id parameter') path = 'comments/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def gamma_public_get_sports(self, params={}): """ fetches sports data from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.league]: filter by league :param str [params.team]: filter by team :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ return await self.request('sports', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) async def gamma_public_get_sports_id(self, params={}): """ fetches a specific sport/team by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the sport/team ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetSportsId() requires an id parameter') path = 'sports/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return await self.request(path, ['gamma', 'public'], 'GET', remainingParams) async def data_public_get_positions(self, params={}): """ fetches current positions for a user from Data-API https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :param str[] [params.market]: comma-separated list of condition IDs(mutually exclusive with eventId) :param int[] [params.eventId]: comma-separated list of event IDs(mutually exclusive with market) :param number [params.sizeThreshold]: minimum size threshold(default: 1) :param boolean [params.redeemable]: filter by redeemable positions(default: False) :param boolean [params.mergeable]: filter by mergeable positions(default: False) :param int [params.limit]: maximum number of results(default: 100, max: 500) :param int [params.offset]: offset for pagination(default: 0, max: 10000) :param str [params.sortBy]: sort field: CURRENT, INITIAL, TOKENS, CASHPNL, PERCENTPNL, TITLE, RESOLVING, PRICE, AVGPRICE(default: TOKENS) :param str [params.sortDirection]: sort direction: ASC, DESC(default: DESC) :param str [params.title]: filter by title(max length: 100) :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetPositions() requires a user parameter') return await self.request('positions', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_trades(self, params={}): """ fetches trades for a user or markets from Data-API https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(optional, filter by user) :param str[] [params.market]: comma-separated list of condition IDs(optional, filter by markets) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ return await self.request('trades', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_activity(self, params={}): """ fetches user activity from Data-API https://docs.polymarket.com/api-reference/core/get-user-activity :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetActivity() requires a user parameter') return await self.request('activity', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_holders(self, params={}): """ fetches top holders for markets from Data-API https://docs.polymarket.com/api-reference/core/get-top-holders-for-markets :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.market]: comma-separated list of condition IDs(required) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ market = self.safe_string(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' dataPublicGetHolders() requires a market parameter') return await self.request('holders', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_total_value(self, params={}): """ fetches total value of a user's positions from Data-API https://docs.polymarket.com/api-reference/core/get-total-value-of-a-users-positions :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetTotalValue() requires a user parameter') return await self.request('value', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_closed_positions(self, params={}): """ fetches closed positions for a user from Data-API https://docs.polymarket.com/api-reference/core/get-closed-positions-for-a-user :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :param str[] [params.market]: comma-separated list of condition IDs(mutually exclusive with eventId) :param int[] [params.eventId]: comma-separated list of event IDs(mutually exclusive with market) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :param str [params.sortBy]: sort field :param str [params.sortDirection]: sort direction: ASC, DESC :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetClosedPositions() requires a user parameter') return await self.request('closed-positions', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_traded(self, params={}): """ fetches total markets a user has traded from Data-API https://docs.polymarket.com/api-reference/misc/get-total-markets-a-user-has-traded :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetTraded() requires a user parameter') return await self.request('traded', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def data_public_get_open_interest(self, params={}): """ fetches open interest from Data-API https://docs.polymarket.com/api-reference/misc/get-open-interest :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.market]: array of condition IDs(required) :returns dict: response from the exchange """ market = self.safe_value(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' dataPublicGetOpenInterest() requires a market parameter') # Convert market to array if it's a single string marketArray: List[str] = [] if isinstance(market, list): marketArray = market elif isinstance(market, str): marketArray = [market] else: raise ArgumentsRequired(self.id + ' dataPublicGetOpenInterest() requires market to be a string or array of condition IDs') # API expects market in query params requestParams = self.extend({'market': marketArray}, self.omit(params, 'market')) return await self.request('oi', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, requestParams)) async def data_public_get_live_volume(self, params={}): """ fetches live volume for an event from Data-API https://docs.polymarket.com/api-reference/misc/get-live-volume-for-an-event :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.eventId]: event ID(required) :returns dict: response from the exchange """ eventId = self.safe_integer(params, 'eventId') if eventId is None: raise ArgumentsRequired(self.id + ' dataPublicGetLiveVolume() requires an eventId parameter') return await self.request('live-volume', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) async def bridge_public_get_supported_assets(self, params={}): """ fetches supported assets for bridging from Bridge API https://docs.polymarket.com/developers/misc-endpoints/bridge-supported-assets :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ return await self.request('supported-assets', ['bridge', 'public'], 'GET', self.extend({'api_type': 'bridge'}, params)) async def bridge_public_post_deposit(self, params={}): """ creates deposit addresses for bridging assets to Polymarket https://docs.polymarket.com/developers/misc-endpoints/bridge-deposit :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.address]: Polymarket wallet address(required) :returns dict: response from the exchange """ address = self.safe_string(params, 'address') if address is None: raise ArgumentsRequired(self.id + ' bridgePublicPostDeposit() requires an address parameter') body = self.json({'address': address}) remainingParams = self.extend({'api_type': 'bridge'}, self.omit(params, 'address')) return await self.request('deposit', ['bridge', 'public'], 'POST', remainingParams, None, body) async def create_deposit_address(self, code: str, params={}): """ create a deposit address for bridging assets to Polymarket https://docs.polymarket.com/developers/misc-endpoints/bridge-deposit :param str code: unified currency code :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.address]: Polymarket wallet address(required if not set in options) :returns dict: an `address structure ` """ # Get address from params or use default from options address = self.safe_string(params, 'address') if address is None: # Try to get from options or raise error address = self.safe_string(self.options, 'address') if address is None: raise ArgumentsRequired(self.id + ' createDepositAddress() requires an address parameter or address in options') response = await self.bridge_public_post_deposit(self.extend({'address': address}, params)) # Response format: {address: "...", depositAddresses: [{chainId, chainName, tokenAddress, tokenSymbol, depositAddress}, ...]} depositAddresses = self.safe_list(response, 'depositAddresses', []) # Find the deposit address for the requested currency code # For Polymarket, all deposits are converted to USDC.e, but we can filter by tokenSymbol currency = self.currency(code) depositAddress = None for i in range(0, len(depositAddresses)): addr = depositAddresses[i] tokenSymbol = self.safe_string(addr, 'tokenSymbol') if tokenSymbol and tokenSymbol.upper() == currency['code'].upper(): depositAddress = self.safe_string(addr, 'depositAddress') break # If not found, return the first deposit address(default to USDC) if depositAddress is None and len(depositAddresses) > 0: depositAddress = self.safe_string(depositAddresses[0], 'depositAddress') return { 'currency': code, 'address': depositAddress, 'tag': None, 'info': response, } async def clob_public_get_orderbook_token_id(self, params={}): """ fetches orderbook for a specific token ID from CLOB API :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetOrderbookTokenId() requires a token_id parameter') # Note: CLOB API uses /book endpoint with token_id parameter, not /orderbook/{token_id} # See https://docs.polymarket.com/developers/CLOB/prices-books/get-book remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('book', ['clob', 'public'], 'GET', remainingParams) async def clob_public_post_books(self, params={}): """ fetches order books for multiple token IDs from CLOB API https://docs.polymarket.com/api-reference/orderbook/get-multiple-order-books-summaries-by-request :param dict [params]: extra parameters specific to the exchange API endpoint :param Array [params.requests]: array of {token_id, limit?} objects(required) :returns dict: response from the exchange """ requests = self.safe_value(params, 'requests') if requests is None or not isinstance(requests, list) or len(requests) == 0: raise ArgumentsRequired(self.id + ' clobPublicPostBooks() requires a requests parameter(array of {token_id, limit?} objects)') # Note: REST API endpoint format: POST /books with JSON body # See https://docs.polymarket.com/api-reference/orderbook/get-multiple-order-books-summaries-by-request # Request body: [{token_id: "..."}, {token_id: "...", limit: 10}, ...] # Response format: array of order book objects, each with asset_id, bids, asks, etc. body = self.json(requests) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'requests')) return await self.request('books', ['clob', 'public'], 'POST', remainingParams, None, body) async def clob_public_get_market_trades_events(self, params={}): """ fetches market trade events for a specific condition ID from CLOB API https://docs.polymarket.com/developers/CLOB/clients/methods-public#getmarkettradesevents https://docs.polymarket.com/developers/CLOB/trades/trades-data-api :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.condition_id]: the condition ID(market ID) for the market :param int [params.limit]: the maximum number of trades to fetch(default: 100, max: 500) :param int [params.offset]: number of trades to skip before starting to return results(default: 0) :param boolean [params.takerOnly]: if True, returns only trades where the user is the taker(default: True) :param str [params.side]: filter by side: 'BUY' or 'SELL' :returns dict: response from the exchange """ conditionId = self.safe_string(params, 'condition_id') if conditionId is None: raise ArgumentsRequired(self.id + ' clobPublicGetMarketTradesEvents() requires a condition_id parameter') # Note: CLOB REST API endpoint format: /trades?market={condition_id} # See https://docs.polymarket.com/developers/CLOB/trades/trades-data-api # The client SDK method getMarketTradesEvents() uses a different endpoint, but the REST API uses /trades request: dict = { 'market': conditionId, } remainingParams = self.omit(params, 'condition_id') # Add optional parameters limit = self.safe_integer(remainingParams, 'limit') if limit is not None: request['limit'] = limit offset = self.safe_integer(remainingParams, 'offset') if offset is not None: request['offset'] = offset takerOnly = self.safe_bool(remainingParams, 'takerOnly') if takerOnly is not None: request['takerOnly'] = takerOnly side = self.safe_string(remainingParams, 'side') if side is not None: request['side'] = side # Add any other remaining params finalParams = self.extend({'api_type': 'clob'}, self.extend(request, self.omit(remainingParams, ['limit', 'offset', 'takerOnly', 'side']))) return await self.request('trades', ['clob', 'public'], 'GET', finalParams) async def clob_public_get_prices_history(self, params={}): """ fetches historical price data for a token from CLOB API https://docs.polymarket.com/developers/CLOB/clients/methods-public#getpriceshistory :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: the token ID(market parameter) :param str [params.interval]: the time interval: "max", "1w", "1d", "6h", "1h" :param int [params.startTs]: timestamp in seconds of the earliest candle to fetch :param int [params.endTs]: timestamp in seconds of the latest candle to fetch :param number [params.fidelity]: data fidelity/quality :returns dict: response from the exchange """ market = self.safe_string(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' clobPublicGetPricesHistory() requires a market(token_id) parameter') # Note: REST API endpoint format: /prices-history # See https://docs.polymarket.com/developers/CLOB/timeseries # Required: market # Time component(mutually exclusive): either(startTs and endTs) OR interval # Optional: fidelity # Response format: {"history": [{"t": timestamp, "p": price}, ...]} request: dict = { 'market': market, } # Add time component - either startTs/endTs OR interval(mutually exclusive) startTs = self.safe_integer(params, 'startTs') endTs = self.safe_integer(params, 'endTs') interval = self.safe_string(params, 'interval') if startTs is not None or endTs is not None: # Use startTs/endTs when provided if startTs is not None: request['startTs'] = startTs if endTs is not None: request['endTs'] = endTs elif interval is not None: # Use interval when startTs/endTs are not provided request['interval'] = interval # Add optional fidelity parameter fidelity = self.safe_number(params, 'fidelity') if fidelity is not None: finalFidelity = fidelity # Polymarket enforces minimum fidelity per interval(e.g. interval=1m requires fidelity>=10) intervalForFidelity = self.safe_string(request, 'interval') if intervalForFidelity == '1m': finalFidelity = max(10, finalFidelity) request['fidelity'] = finalFidelity remainingParams = self.extend({'api_type': 'clob'}, self.extend(request, self.omit(params, ['market', 'startTs', 'endTs', 'fidelity', 'interval']))) return await self.request('prices-history', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_time(self, params={}): """ fetches the current server timestamp from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L178 :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # Based on get_server_time() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(TIME = "/time") return await self.request('time', ['clob', 'public'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_public_get_ok(self, params={}): """ health check endpoint to confirm server is up https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # Based on get_ok() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 return await self.request('', ['clob', 'public'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_public_get_fee_rate(self, params={}): """ fetches the fee rate for a token from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetFeeRate() requires a token_id parameter') # Based on get_fee_rate() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_FEE_RATE = "/fee-rate") remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('fee-rate', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_price(self, params={}): """ fetches the market price for a specific token and side from CLOB API https://docs.polymarket.com/api-reference/pricing/get-market-price :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :param str [params.side]: the side: 'BUY' or 'SELL'(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetPrice() requires a token_id parameter') side = self.safe_string(params, 'side') if side is None: raise ArgumentsRequired(self.id + ' clobPublicGetPrice() requires a side parameter(BUY or SELL)') # Note: REST API endpoint format: /price?token_id={token_id}&side={side} # See https://docs.polymarket.com/api-reference/pricing/get-market-price remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('price', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_prices(self, params={}): """ fetches market prices for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch prices for :param str [params.side]: the side: 'BUY' or 'SELL'(required if token_ids provided) :returns dict: response from the exchange """ # Note: REST API endpoint format: /prices?token_id={token_id1,token_id2,...} # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices # Response format: {[token_id]: {BUY: "price", SELL: "price"}, ...} # The endpoint returns both BUY and SELL prices for each token_id remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('prices', ['clob', 'public'], 'GET', remainingParams) async def clob_public_post_prices(self, params={}): """ fetches market prices for specified tokens and sides via POST request https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request :param dict [params]: extra parameters specific to the exchange API endpoint :param Array [params.requests]: array of {token_id, side} objects(required) :returns dict: response from the exchange """ requests = self.safe_value(params, 'requests') if requests is None: raise ArgumentsRequired(self.id + ' clobPublicPostPrices() requires a requests parameter(array of {token_id, side} objects)') # Note: REST API endpoint format: POST /prices with JSON body # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request # Body format: [{"token_id": "1234567890", "side": "BUY"}, {"token_id": "1234567890", "side": "SELL"}] # Response format: {[token_id]: {BUY: "price", SELL: "price"}, ...} body = self.json(requests) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'requests')) return await self.request('prices', ['clob', 'public'], 'POST', remainingParams, None, body) async def clob_public_get_midpoint(self, params={}): """ fetches the midpoint price for a specific token from CLOB API https://docs.polymarket.com/api-reference/pricing/get-midpoint-price :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetMidpoint() requires a token_id parameter') # Note: REST API endpoint format: /midpoint?token_id={token_id} # See https://docs.polymarket.com/api-reference/pricing/get-midpoint-price remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('midpoint', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_midpoints(self, params={}): """ fetches midpoint prices for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/pricing/get-midpoint-prices :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch midpoints for(required) :returns dict: response from the exchange """ tokenIds = self.safe_value(params, 'token_ids') if tokenIds is None or not isinstance(tokenIds, list) or len(tokenIds) == 0: raise ArgumentsRequired(self.id + ' clobPublicGetMidpoints() requires a token_ids parameter(array of token IDs)') # Note: REST API endpoint format: POST /midpoints with JSON body # See https://docs.polymarket.com/api-reference/pricing/get-midpoint-prices # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: {[token_id]: "midpoint", ...} body: List[Any] = [] for i in range(0, len(tokenIds)): body.append({'token_id': tokenIds[i]}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'token_ids')) return await self.request('midpoints', ['clob', 'public'], 'POST', remainingParams, None, self.json(body)) async def clob_public_get_spread(self, params={}): """ fetches the bid-ask spread for a specific token from CLOB API https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spread :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetSpread() requires a token_id parameter') # Note: REST API endpoint format: /spread?token_id={token_id} # See https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spread remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('spread', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_last_trade_price(self, params={}): """ fetches the last trade price for a specific token from CLOB API https://docs.polymarket.com/api-reference/trades/get-last-trade-price :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetLastTradePrice() requires a token_id parameter') # Note: REST API endpoint format: /last-trade-price?token_id={token_id} # See https://docs.polymarket.com/api-reference/trades/get-last-trade-price remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('last-trade-price', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_last_trades_prices(self, params={}): """ fetches last trade prices for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/trades/get-last-trades-prices :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch last trade prices for(required) :returns dict: response from the exchange """ tokenIds = self.safe_value(params, 'token_ids') if tokenIds is None or not isinstance(tokenIds, list) or len(tokenIds) == 0: raise ArgumentsRequired(self.id + ' clobPublicGetLastTradesPrices() requires a token_ids parameter(array of token IDs)') # Note: REST API endpoint format: POST /last-trades-prices with JSON body # See https://docs.polymarket.com/api-reference/trades/get-last-trades-prices # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: {[token_id]: "price", ...} body: List[Any] = [] for i in range(0, len(tokenIds)): body.append({'token_id': tokenIds[i]}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'token_ids')) return await self.request('last-trades-prices', ['clob', 'public'], 'POST', remainingParams, None, self.json(body)) async def clob_public_get_trades(self, params={}): """ fetches trades for a specific market from CLOB API https://docs.polymarket.com/developers/CLOB/clients/methods-public#trades :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: the token ID or condition ID(required) :param int [params.limit]: maximum number of trades to return(default: 100, max: 500) :param str [params.side]: filter by side: 'BUY' or 'SELL' :param int [params.start_timestamp]: start timestamp in seconds :param int [params.end_timestamp]: end timestamp in seconds :returns dict: response from the exchange """ market = self.safe_string(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' clobPublicGetTrades() requires a market(token_id or condition_id) parameter') # Note: REST API endpoint format: /trades?market={token_id} # See https://docs.polymarket.com/developers/CLOB/clients/methods-public#trades request: dict = { 'market': market, } limit = self.safe_integer(params, 'limit') if limit is not None: request['limit'] = min(limit, 500) # Cap at 500 side = self.safe_string(params, 'side') if side is not None: request['side'] = side startTimestamp = self.safe_integer(params, 'start_timestamp') if startTimestamp is not None: request['start_timestamp'] = startTimestamp endTimestamp = self.safe_integer(params, 'end_timestamp') if endTimestamp is not None: request['end_timestamp'] = endTimestamp remainingParams = self.extend({'api_type': 'clob'}, self.extend(request, self.omit(params, ['market', 'limit', 'side', 'start_timestamp', 'end_timestamp']))) return await self.request('trades', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_tick_size(self, params={}): """ fetches the tick size for a token from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetTickSize() requires a token_id parameter') # Based on get_tick_size() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_TICK_SIZE = "/tick-size") remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('tick-size', ['clob', 'public'], 'GET', remainingParams) async def clob_public_get_neg_risk(self, params={}): """ fetches the negative risk flag for a token from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetNegRisk() requires a token_id parameter') # Based on get_neg_risk() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_NEG_RISK = "/neg-risk") remainingParams = self.extend({'api_type': 'clob'}, params) return await self.request('neg-risk', ['clob', 'public'], 'GET', remainingParams) async def clob_public_post_spreads(self, params={}): """ fetches bid-ask spreads for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spreads :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch spreads for(required) :returns dict: response from the exchange """ tokenIds = self.safe_value(params, 'token_ids') if tokenIds is None or not isinstance(tokenIds, list) or len(tokenIds) == 0: raise ArgumentsRequired(self.id + ' clobPublicPostSpreads() requires a token_ids parameter(array of token IDs)') # Note: REST API endpoint format: POST /spreads # See https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spreads # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: {[token_id]: "spread", ...} body: List[Any] = [] for i in range(0, len(tokenIds)): body.append({'token_id': tokenIds[i]}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'token_ids')) return await self.request('spreads', ['clob', 'public'], 'POST', remainingParams, None, self.json(body)) async def clob_private_get_order(self, params={}): """ fetches a specific order by order ID https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_ORDER = "/data/order/") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID(required) :returns dict: response from the exchange """ orderId = self.safe_string(params, 'order_id') if orderId is None: raise ArgumentsRequired(self.id + ' clobPrivateGetOrder() requires an order_id parameter') path = 'data/order/' + self.encode_uri_component(orderId) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'order_id')) return await self.request(path, ['clob', 'private'], 'GET', remainingParams) async def clob_private_get_orders(self, params={}): """ fetches orders for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(ORDERS = "/data/orders") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: filter orders by token ID :param str [params.status]: filter orders by status(OPEN, FILLED, CANCELLED, etc.) :returns dict: response from the exchange """ return await self.request('data/orders', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_private_post_order(self, params={}): """ creates a new order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(POST_ORDER = "/order") https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters :param dict [params]: extra parameters specific to the exchange API endpoint :param dict [params.order]: order object(required) :param str [params.owner]: api key of order owner(required) :param str [params.orderType]: order type: "FOK", "GTC", "GTD"(required) :returns dict: response from the exchange """ # Build request payload according to API specification # See https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters order = self.safe_value(params, 'order') if order is None: raise ArgumentsRequired(self.id + ' clobPrivatePostOrder() requires an order parameter') owner = self.safe_string(params, 'owner') if owner is None: raise ArgumentsRequired(self.id + ' clobPrivatePostOrder() requires an owner parameter(API key)') orderType = self.safe_string(params, 'orderType') if orderType is None: raise ArgumentsRequired(self.id + ' clobPrivatePostOrder() requires an orderType parameter') # Build the complete request payload with top-level fields requestPayload: dict = { 'order': order, 'owner': owner, 'orderType': orderType, } # Add optional parameters if provided clientOrderId = self.safe_string(params, 'clientOrderId') if clientOrderId is not None: requestPayload['clientOrderId'] = clientOrderId postOnly = self.safe_bool(params, 'postOnly') if postOnly is not None: requestPayload['postOnly'] = postOnly # Send the complete request payload body body = self.json(requestPayload) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, ['order', 'owner', 'orderType', 'clientOrderId', 'postOnly'])) return await self.request('order', ['clob', 'private'], 'POST', remainingParams, None, body) async def clob_private_post_orders(self, params={}): """ creates multiple orders in a batch https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(POST_ORDERS = "/orders") :param dict [params]: extra parameters specific to the exchange API endpoint :param Array [params.orders]: array of order objects(required) :returns dict: response from the exchange """ orders = self.safe_value(params, 'orders') if orders is None or not isinstance(orders, list): raise ArgumentsRequired(self.id + ' clobPrivatePostOrders() requires an orders parameter(array of order objects)') body = self.json(orders) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'orders')) return await self.request('orders', ['clob', 'private'], 'POST', remainingParams, None, body) async def clob_private_delete_order(self, params={}): """ cancels an order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL = "/order") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID to cancel(required) :returns dict: response from the exchange """ orderId = self.safe_string(params, 'order_id') if orderId is None: raise ArgumentsRequired(self.id + ' clobPrivateDeleteOrder() requires an order_id parameter') request: dict = { 'orderID': orderId, } remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'order_id')) body = self.json(request) return await self.request('order', ['clob', 'private'], 'DELETE', remainingParams, None, body) async def clob_private_delete_orders(self, params={}): """ cancels multiple orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ORDERS = "/orders") :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.order_ids]: array of order IDs to cancel(required) :returns dict: response from the exchange """ orderIds = self.safe_value(params, 'order_ids') if orderIds is None or not isinstance(orderIds, list): raise ArgumentsRequired(self.id + ' clobPrivateDeleteOrders() requires an order_ids parameter(array of order IDs)') body = self.json(orderIds) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'order_ids')) return await self.request('orders', ['clob', 'private'], 'DELETE', remainingParams, None, body) async def clob_private_delete_cancel_all(self, params={}): """ cancels all open orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ALL = "/cancel-all") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: optional token ID to cancel all orders for a specific market :returns dict: response from the exchange """ body = self.json(params) return await self.request('cancel-all', ['clob', 'private'], 'DELETE', {'api_type': 'clob'}, None, body) async def clob_private_delete_cancel_market_orders(self, params={}): """ cancels all orders from a market https://docs.polymarket.com/developers/CLOB/orders/cancel-market-orders :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: condition id of the market :param str [params.asset_id]: id of the asset/token :returns dict: response from the exchange """ request: dict = {} market = self.safe_string(params, 'market') if market is not None: request['market'] = market assetId = self.safe_string(params, 'asset_id') if assetId is not None: request['asset_id'] = assetId remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, ['market', 'asset_id'])) body = self.json(request) return await self.request('cancel-market-orders', ['clob', 'private'], 'DELETE', remainingParams, None, body) async def clob_private_get_trades(self, params={}): """ fetches trade history for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py(get_trades method) :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: filter trades by token ID :param int [params.start_timestamp]: start timestamp in seconds :param str [params.next_cursor]: pagination cursor :returns dict: response from the exchange """ # NOTE: the authenticated L2 endpoint is `/trades`(without the public `/data/` prefix). # Using the public path would return all market trades instead of the caller's own fills. return await self.request('trades', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_private_get_builder_trades(self, params={}): """ fetches trades originated by the builder https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_BUILDER_TRADES = "/builder-trades") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: filter trades by token ID :param int [params.start_timestamp]: start timestamp in seconds :param str [params.next_cursor]: pagination cursor :returns dict: response from the exchange """ return await self.request('builder-trades', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_private_get_notifications(self, params={}): """ fetches notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_NOTIFICATIONS = "/notifications") :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ return await self.request('notifications', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_private_delete_notifications(self, params={}): """ drops notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(DROP_NOTIFICATIONS = "/notifications") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.notification_id]: specific notification ID to drop :returns dict: response from the exchange """ return await self.request('notifications', ['clob', 'private'], 'DELETE', self.extend({'api_type': 'clob'}, params)) async def clob_private_get_balance_allowance(self, params={}): """ fetches balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_BALANCE_ALLOWANCE = "/balance-allowance") :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ return await self.request('balance-allowance', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_private_put_balance_allowance(self, params={}): """ updates balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(UPDATE_BALANCE_ALLOWANCE = "/balance-allowance") :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ body = self.json(params) return await self.request('balance-allowance', ['clob', 'private'], 'PUT', {'api_type': 'clob'}, None, body) async def clob_private_get_is_order_scoring(self, params={}): """ checks if an order is currently scoring https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(IS_ORDER_SCORING = "/is-order-scoring") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID(required) :param str [params.token_id]: the token ID(required) :param str [params.side]: the side: 'BUY' or 'SELL'(required) :param str [params.price]: the price(required) :param str [params.size]: the size(required) :returns dict: response from the exchange """ # GET /order-scoring?order_id=... return await self.request('order-scoring', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) async def clob_private_post_are_orders_scoring(self, params={}): """ checks if multiple orders are currently scoring https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(ARE_ORDERS_SCORING = "/are-orders-scoring") :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.orderIds]: array of order IDs to check(required) :returns dict: response from the exchange """ orderIds = self.safe_value_2(params, 'orderIds', 'order_ids') if orderIds is None or not isinstance(orderIds, list): raise ArgumentsRequired(self.id + ' clobPrivatePostAreOrdersScoring() requires an orderIds parameter(array of order IDs)') body = self.json({'orderIds': orderIds}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, ['orderIds', 'order_ids'])) # POST /orders-scoring with JSON body {orderIds: [...]} return await self.request('orders-scoring', ['clob', 'private'], 'POST', remainingParams, None, body) def get_main_wallet_address(self): """ gets main wallet address(walletAddress or options.funder) :returns str: main wallet address """ if self.walletAddress is not None and self.walletAddress != '': return self.walletAddress funder = self.safe_string(self.options, 'funder') if funder is not None and funder != '': return funder raise ArgumentsRequired(self.id + ' getMainWalletAddress() requires a wallet address. Set `walletAddress` or `options.funder`.') def get_proxy_wallet_address(self): """ gets proxy wallet address for Data-API endpoints(falls back to main wallet if not set) :returns str: proxy wallet address """ if self.uid is not None and self.uid != '': return self.uid proxyWallet = self.safe_string(self.options, 'proxyWallet') if proxyWallet is not None and proxyWallet != '': return proxyWallet # Fall back to main wallet if proxyWallet is not set return self.get_main_wallet_address() def get_builder_wallet_address(self): """ gets builder wallet address(falls back to main wallet if not set) :returns str: builder wallet address """ builderWallet = self.safe_string(self.options, 'builderWallet') if builderWallet is not None and builderWallet != '': return builderWallet # Fall back to main wallet if builderWallet is not set return self.get_main_wallet_address() async def get_user_total_value(self, userAddress: str = None) -> dict: """ fetches total value of a user's positions from Data-API https://docs.polymarket.com/api-reference/core/get-total-value-of-a-users-positions :param str [userAddress]: user wallet address(defaults to getProxyWalletAddress()) :returns dict: object with 'value'(number) and 'response'(raw API response) """ address: str = None if userAddress is not None: # Use provided address directly(public endpoint, no wallet setup needed) address = userAddress else: # Try to get proxy wallet address, but handle case where wallet is not configured # This allows public calls without requiring wallet setup try: address = self.get_proxy_wallet_address() except Exception as e: # If wallet is not configured, require userAddress parameter for public calls raise ArgumentsRequired(self.id + ' getUserTotalValue() requires a userAddress parameter when wallet is not configured. This is a public endpoint that can query any user address.') # Fetch total value from Data-API valueResponse = await self.data_public_get_total_value({'user': address}) # Response format: [{"user": "0x...", "value": 123}] valueData = valueResponse if isinstance(valueResponse, list): if len(valueResponse) > 0: valueData = valueResponse[0] else: valueData = {} totalValue = self.safe_number(valueData, 'value', 0) return { 'value': totalValue, 'response': valueResponse, } async def get_user_positions(self, userAddress: str = None, params={}) -> dict: """ fetches current positions for a user from Data-API(defaults to proxy wallet) https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user :param str [userAddress]: user wallet address(defaults to getProxyWalletAddress()) :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # TODO add pagination, sort, limit etc https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user address: str = None if userAddress is not None: # Use provided address directly(public endpoint, no wallet setup needed) address = userAddress else: # Try to get proxy wallet address, but handle case where wallet is not configured # This allows public calls without requiring wallet setup try: address = self.get_proxy_wallet_address() except Exception as e: # If wallet is not configured, require userAddress parameter for public calls raise ArgumentsRequired(self.id + ' getUserPositions() requires a userAddress parameter when wallet is not configured. This is a public endpoint that can query any user address.') return await self.data_public_get_positions(self.extend({'user': address}, params)) async def get_user_activity(self, userAddress: str = None, params={}) -> dict: """ fetches user activity from Data-API(defaults to proxy wallet) https://docs.polymarket.com/api-reference/core/get-user-activity :param str [userAddress]: user wallet address(defaults to getProxyWalletAddress()) :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ address: str = None if userAddress is not None: # Use provided address directly(public endpoint, no wallet setup needed) address = userAddress else: # Try to get proxy wallet address, but handle case where wallet is not configured # This allows public calls without requiring wallet setup try: address = self.get_proxy_wallet_address() except Exception as e: # If wallet is not configured, require userAddress parameter for public calls raise ArgumentsRequired(self.id + ' getUserActivity() requires a userAddress parameter when wallet is not configured. This is a public endpoint that can query any user address.') request: dict = { 'user': address, 'limit': self.safe_integer(params, 'limit', 100), 'offset': self.safe_integer(params, 'offset', 0), 'sortBy': self.safe_string(params, 'sortBy', 'TIMESTAMP'), 'sortDirection': self.safe_string(params, 'sortDirection', 'DESC'), } return await self.data_public_get_activity(self.extend(request, self.omit(params, ['user']))) def parse_user_activity(self, activity: dict, market: Market = None) -> dict: """ parse a raw user activity record into a trade-like structure consumable by parseTrades :param dict activity: raw activity payload from Data-API :param dict [market]: market structure, when known :returns dict|None: normalized activity(only for TRADE records) or None """ activityType = self.safe_string(activity, 'type') if activityType != 'TRADE': return None rawTs = self.safe_integer(activity, 'timestamp') isoTimestamp = self.safe_string(activity, 'timestamp') if rawTs is not None: tsMs = rawTs * 1000 if (rawTs < 1000000000000) else rawTs isoTimestamp = self.iso8601(tsMs) symbol = market['symbol'] if (market is not None) else self.safe_string(activity, 'condition_id') return self.extend(activity, { 'timestamp': isoTimestamp, 'transactionHash': self.safe_string(activity, 'transactionHash'), 'symbol': symbol, 'asset': self.safe_string(activity, 'asset'), 'price': self.safe_number(activity, 'price'), 'size': self.safe_number(activity, 'size'), 'side': self.safe_string(activity, 'side'), }) def format_address(self, address: str = None): if address is None: return None if address.startswith('0x'): return address.replace('0x', '') return address def normalize_address(self, address: str) -> str: normalized = str(address).strip() if not normalized.startswith('0x'): normalized = '0x' + normalized return normalized.lower() def hash_message(self, message: str) -> str: binaryMessage = self.encode(message) binaryMessageLength = self.binary_length(binaryMessage) x19 = self.base16_to_binary('19') newline = self.base16_to_binary('0a') prefix = self.binary_concat(x19, self.encode('Ethereum Signed Message:'), newline, self.encode(self.number_to_string(binaryMessageLength))) return '0x' + self.hash(self.binary_concat(prefix, binaryMessage), 'keccak', 'hex') def get_contract_config(self, chainID: float) -> dict: contracts = self.safe_value(self.options, 'contracts', {}) chainIdStr = str(chainID) contractConfig = self.safe_value(contracts, chainIdStr) if contractConfig is None: raise ExchangeError(self.id + ' getContractConfig() invalid network chainId: ' + chainIdStr) return contractConfig def sign_message(self, message: str, privateKey: str) -> str: hash = self.hash_message(message) return self.sign_hash(hash, privateKey) def sign_hash(self, hash: str, privateKey: str): signature = self.ecdsa(hash[-64:], privateKey[-64:], 'secp256k1', None) r = signature['r'] s = signature['s'] v = self.int_to_base16(self.sum(27, signature['v'])) # Convert to lowercase hex(Ethereum standard) finalSignature = ('0x' + r.rjust(64, '0') + s.rjust(64, '0') + v.rjust(2, '0')).lower() return finalSignature def sign_typed_data(self, domain: dict, types: dict, value: dict) -> str: # This returns binary data: 0x1901 or hashDomain(domain) or hashStruct(primaryType, types, value) encoded = self.eth_encode_structured_data(domain, types, value) # Hash the encoded binary data with keccak256 hash = '0x' + self.hash(encoded, 'keccak', 'hex') # Sign the hash using signHash signature = self.sign_hash(hash, self.privateKey) return signature def create_level1_headers(self, walletAddress: str, nonce: float = None) -> dict: if walletAddress is None or walletAddress == '': raise ArgumentsRequired(self.id + ' createLevel1Headers() requires a valid walletAddress') normalizedAddress = self.normalize_address(walletAddress) chainId = self.safe_integer(self.options, 'chainId') timestampSeconds = int(math.floor(self.milliseconds()) / 1000) timestamp = str(timestampSeconds) nonceValue = 0 if nonce is not None: nonceValue = nonce clobDomainName = self.safe_string(self.options, 'clobDomainName') clobVersion = self.safe_string(self.options, 'clobVersion') msgToSign = self.safe_string(self.options, 'msgToSign') domain = { 'name': clobDomainName, 'version': clobVersion, 'chainId': chainId, } # https://github.com/Polymarket/clob-client/blob/b75aec68be17190215b7230372fbedfe85de20ef/src/signing/eip712.ts#L28 types = { 'ClobAuth': [ {'name': 'address', 'type': 'address'}, {'name': 'timestamp', 'type': 'string'}, {'name': 'nonce', 'type': 'uint256'}, {'name': 'message', 'type': 'string'}, ], } message = { 'address': normalizedAddress, 'timestamp': timestamp, 'nonce': nonceValue, 'message': msgToSign, } signature = self.sign_typed_data(domain, types, message) headers = { 'POLY_ADDRESS': normalizedAddress, 'POLY_TIMESTAMP': timestamp, 'POLY_NONCE': str(nonceValue), 'POLY_SIGNATURE': signature, } return headers def get_clob_base_url(self, params={}) -> str: """ Gets the CLOB API base URL(handles sandbox mode and custom hosts) :param dict [params]: extra parameters :returns str: base URL for CLOB API """ apiType = self.safe_string(params, 'api_type', 'clob') baseUrl = self.urls['api'][apiType] # Check for sandbox mode if self.isSandboxModeEnabled and self.urls['test'] and self.urls['test'][apiType]: baseUrl = self.urls['test'][apiType] if apiType == 'clob': customHost = self.safe_string(self.options, 'clobHost') if customHost is not None: baseUrl = customHost return baseUrl def parse_api_credentials(self, response: Any) -> dict: """ Parses API credentials from API response and caches them :param dict response: API response :returns dict} API credentials {apiKey, secret, passphrase: """ apiKey = self.safe_string(response, 'apiKey') or self.safe_string(response, 'api_key') secret = self.safe_string(response, 'secret') passphrase = self.safe_string(response, 'passphrase') if not apiKey or not secret or not passphrase: raise ExchangeError(self.id + ' parseApiCredentials() failed to parse credentials. Response: ' + self.json(response)) credentials = { 'apiKey': apiKey, 'secret': secret, 'passphrase': passphrase, } # Cache credentials in options self.options['apiCredentials'] = credentials # Also set them properties for use in sign() method self.apiKey = apiKey self.secret = secret self.password = passphrase return credentials async def create_api_key(self, params={}) -> dict: """ Creates a new CLOB API key for the given address https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/authentication :param dict [params]: extra parameters specific to the exchange API endpoint :param number [params.nonce]: optional nonce/timestamp :returns dict} API credentials {apiKey, secret, passphrase: @note Uses manual URL building instead of self.request() because self endpoint requires L1 authentication (POLY_ADDRESS, POLY_SIGNATURE headers) rather than the standard L2 authentication used by self.request() """ if self.privateKey is None: raise ArgumentsRequired(self.id + ' create_api_key() requires a privateKey') # Validate privateKey format(should be hex string with 0x prefix, 66 chars total) if not self.privateKey.startswith('0x') or len(self.privateKey) != 66: raise ArgumentsRequired(self.id + ' create_api_key() requires a valid privateKey(0x-prefixed hex string, 66 characters)') walletAddress = self.get_main_wallet_address() # Validate walletAddress format(should be hex string with 0x prefix, 42 chars total) if not walletAddress.startswith('0x') or len(walletAddress) != 42: raise ArgumentsRequired(self.id + ' create_api_key() requires a valid walletAddress(0x-prefixed hex string, 42 characters). Got: ' + walletAddress) baseUrl = self.get_clob_base_url(params) nonce = self.safe_integer(params, 'nonce') headers = self.create_level1_headers(walletAddress, nonce) url = baseUrl + '/auth/api-key' # POST /auth/api-key(creates new API credentials with L1 authentication) response = await self.fetch(url, 'POST', headers, None) return self.parse_api_credentials(response) async def derive_api_key(self, params={}) -> dict: """ Derives an already existing CLOB API key for the given address and nonce https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/authentication :param dict [params]: extra parameters specific to the exchange API endpoint :param number [params.nonce]: optional nonce/timestamp :returns dict} API credentials {apiKey, secret, passphrase: @note Uses manual URL building instead of self.request() because self endpoint requires L1 authentication (POLY_ADDRESS, POLY_SIGNATURE headers) rather than the standard L2 authentication used by self.request() """ if self.privateKey is None: raise ArgumentsRequired(self.id + ' derive_api_key() requires a privateKey') walletAddress = self.get_main_wallet_address() baseUrl = self.get_clob_base_url(params) nonce = self.safe_integer(params, 'nonce') headers = self.create_level1_headers(walletAddress, nonce) url = baseUrl + '/auth/derive-api-key' # GET /auth/derive-api-key(derives existing API credentials with L1 authentication) response = await self.fetch(url, 'GET', headers, None) return self.parse_api_credentials(response) async def create_or_derive_api_creds(self, params={}) -> dict: """ Creates API creds if not already created for nonce, otherwise derives them https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param number [params.nonce]: optional nonce/timestamp :returns dict} API credentials {apiKey, secret, passphrase: """ # Check if credentials are already cached cachedCreds = self.safe_dict(self.options, 'apiCredentials') if cachedCreds is not None: return cachedCreds # Try create_api_key first, then derive_api_key if create fails # Based on py-clob-client client.py: create_or_derive_api_creds() try: return await self.create_api_key(params) except Exception as e: # If create fails(e.g., key already exists), try to derive it return await self.derive_api_key(params) def set_api_creds(self, credentials: dict): """ Sets API credentials(alias for caching credentials) https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict credentials: API credentials {apiKey, secret, passphrase} """ self.options['apiCredentials'] = credentials self.apiKey = self.safe_string(credentials, 'apiKey') self.secret = self.safe_string(credentials, 'secret') self.password = self.safe_string(credentials, 'passphrase') def get_api_base_url(self, params={}) -> str: """ Gets the API base URL for the specified API type(handles sandbox mode and custom hosts) :param dict [params]: extra parameters :param str [params.api_type]: API type('clob', 'gamma', 'data', etc.) :returns str: base URL for the API """ apiType = self.safe_string(params, 'api_type', 'clob') # Ensure urls.api exists if self.urls is None or self.urls['api'] is None: raise ExchangeError(self.id + ' getApiBaseUrl() failed: urls.api is not initialized. Make sure exchange is properly initialized.') # Direct access to nested object property baseUrl = self.urls['api'][apiType] # Check for sandbox mode if self.isSandboxModeEnabled and self.urls['test'] and self.urls['test'][apiType]: baseUrl = self.urls['test'][apiType] # Allow custom CLOB host override if apiType == 'clob': customHost = self.safe_string(self.options, 'clobHost') if customHost is not None: baseUrl = customHost # Ensure we have a valid base URL if baseUrl is None: apiUrls = self.urls['api'] or {} availableTypesList = list(apiUrls.keys()) availableTypes = '' if len(availableTypesList) > 0: availableTypes = ', '.join(availableTypesList) raise ExchangeError(self.id + ' getApiBaseUrl() failed: API type "' + apiType + '" not found in urls.api. Available types: ' + availableTypes) return baseUrl def build_default_headers(self, method: str, existingHeaders: dict = None) -> dict: """ Builds default HTTP headers based on py-clob-client helpers.py https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/http_helpers/helpers.py :param str method: HTTP method('GET', 'POST', etc.) :param dict [existingHeaders]: existing headers to self.extend :returns dict: headers dictionary """ if existingHeaders is None: existingHeaders = {} headers = self.extend({ 'User-Agent': 'ccxt', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Type': 'application/json', }, existingHeaders) # Add Accept-Encoding for GET requests(as per py-clob-client) if method == 'GET': headers['Accept-Encoding'] = 'gzip' return headers def build_public_request(self, baseUrl: str, pathWithParams: str, method: str, queryParams: dict, body: str = None, headers: dict = None) -> dict: """ Builds a public(unauthenticated) request :param str baseUrl: API base URL :param str pathWithParams: path with parameters :param str method: HTTP method :param dict queryParams: query parameters :param str [body]: request body :param dict [headers]: request headers :returns dict: request object with url, method, body, and headers """ headers = self.build_default_headers(method, headers) url = baseUrl + '/' + pathWithParams if method == 'GET': if queryParams: url += '?' + self.urlencode(queryParams) else: # For POST requests, body should already be set by the calling method if body is None and queryParams: body = self.json(queryParams) return {'url': url, 'method': method, 'body': body, 'headers': headers} async def ensure_api_credentials(self, params={}) -> dict: """ Ensures API credentials are generated(lazy generation, similar to dYdX's retrieveCredentials) :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict} API credentials {apiKey, secret, passphrase: """ # Check if credentials are already cached cachedCreds = self.safe_dict(self.options, 'apiCredentials') if cachedCreds is not None: return cachedCreds # Check if credentials are provided directly(apiKey, secret, password) # This allows users to provide credentials directly instead of generating from privateKey if self.apiKey and self.secret and self.password: directCreds = { 'apiKey': self.apiKey, 'secret': self.secret, 'passphrase': self.password, } self.set_api_creds(directCreds) return directCreds # If direct credentials not provided, check if privateKey is available for generation if self.privateKey is None: raise ArgumentsRequired(self.id + ' ensureApiCredentials() requires either: (1) apiKey + secret + password provided directly, or (2) privateKey to generate credentials') # Generate credentials lazily(similar to dYdX's retrieveCredentials pattern) # This is called automatically before authenticated requests creds = await self.create_or_derive_api_creds(params) self.set_api_creds(creds) return creds def get_api_credentials(self) -> dict: """ Gets API credentials from cache or instance properties :returns dict} API credentials {apiKey, secret, password: """ apiKey = self.apiKey secret = self.secret password = self.password # Check if credentials are already cached cachedCreds = self.safe_dict(self.options, 'apiCredentials') if cachedCreds is not None: apiKey = self.safe_string(cachedCreds, 'apiKey') or apiKey secret = self.safe_string(cachedCreds, 'secret') or secret password = self.safe_string(cachedCreds, 'passphrase') or password # If credentials are not available, check if privateKey is set # Only raise error if privateKey is set(meaning user wants authenticated requests) # This allows public requests to work even when privateKey is set but credentials not yet generated if not apiKey or not secret or not password: if self.privateKey is None: # No privateKey set - self should not happen if called from buildPrivateRequest raise ArgumentsRequired(self.id + ' getApiCredentials() called but no credentials available and no privateKey set. This should only be called for authenticated requests. Provide either: (1) apiKey + secret + password directly, or (2) privateKey to generate credentials.') # privateKey is set but credentials not generated yet - self is expected for lazy generation # Don't raise error here, ensureApiCredentials() handle it raise ArgumentsRequired(self.id + ' API credentials not generated. Credentials are automatically generated on first authenticated request, but privateKey is required. Alternatively, provide apiKey + secret + password directly.') return {'apiKey': apiKey, 'secret': secret, 'password': password} def build_request_path_and_payload(self, pathWithParams: str, method: str, queryParams: dict, body: str = None) -> dict: """ Builds the request path and payload for signature :param str pathWithParams: path with parameters :param str method: HTTP method :param dict queryParams: query parameters :param str [body]: request body :returns dict} {requestPath, url, payload, body: """ # Ensure path doesn't have double slashes(pathWithParams may already start with /) normalizedPath = pathWithParams if pathWithParams.startswith('/') else '/' + pathWithParams requestPath = normalizedPath url = requestPath payload = '' if method == 'GET': if queryParams: queryString = self.urlencode(queryParams) url += '?' + queryString payload = queryString else: # For POST/PUT/DELETE, body is part of the signature # Use deterministic JSON serialization(no spaces, compact) matching py-clob-client # json.dumps(body, separators=(",", ":"), ensure_ascii=False) produces compact JSON if body is None and queryParams: # json.dumpsby default produces compact JSON(no spaces) body = json.dumps(queryParams) # Serialize body deterministically if it's an object if body is not None and isinstance(body, dict): body = json.dumps(body) # Use body(quote replacement happens in createLevel2Signature) payload = str(body) if (body is not None and body != '') else '' return {'requestPath': requestPath, 'url': url, 'payload': payload, 'body': body} def create_level2_signature(self, timestamp: str, method: str, requestPath: str, body: str, secret: str) -> str: """ Creates Level 2 authentication signature(HMAC-SHA256) https://docs.polymarket.com/developers/CLOB/authentication :param str timestamp: timestamp string :param str method: HTTP method :param str requestPath: request path :param str body: request body(serialized JSON string) :param str secret: API secret(base64 encoded, URL-safe) :returns str: URL-safe base64 encoded signature """ # Create signature: HMAC-SHA256(timestamp + method + path + body, secret) # Based on Polymarket CLOB API L2 authentication(matches py-clob-client build_hmac_signature) # Use str(method) to preserve case(don't use toUpperCase()) message = str(timestamp) + str(method) + str(requestPath) # Only add body if it exists and is not empty # NOTE: Replace single quotes with double quotes(matching py-clob-client behavior) # This is necessary to generate the same hmac message and typescript messageWithBody = message if body is not None and body != '': messageWithBody = message + str(body).replace("'", '"') # Generate HMAC and return URL-safe base64 # Convert URL-safe base64 to standard base64(replace - with + and _ with /) secretBinary = self.base64_to_binary(str(secret).replace('-', '+').replace('_', '/')) hmacResult = self.hmac(self.encode(messageWithBody), secretBinary, hashlib.sha256, 'base64') return hmacResult.replace('+', '-').replace('/', '_') def create_level2_headers(self, apiKey: str, timestamp: str, signature: str, password: str) -> dict: """ Creates Level 2 authentication headers https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/headers/headers.py :param str apiKey: API key :param str timestamp: timestamp string :param str signature: signature string :param str password: API passphrase :returns dict: Level 2 headers dictionary """ authHeaders: dict = { 'POLY_API_KEY': apiKey, 'POLY_TIMESTAMP': timestamp, 'POLY_SIGNATURE': signature, 'POLY_PASSPHRASE': password, # Passphrase is required for L2 authentication 'Content-Type': 'application/json', } # Always include POLY_ADDRESS in Level 2 headers(matches GitHub issue #190 fix) # Get wallet address from funder option, walletAddress property, or derive from privateKey walletAddress = self.safe_string(self.options, 'funder') if walletAddress is None and self.walletAddress is not None: walletAddress = self.walletAddress if walletAddress is None and self.privateKey is not None: # Derive wallet address from private key if not provided walletAddress = self.get_main_wallet_address() if walletAddress is not None: # Normalize and checksum the address(EIP-55) walletAddress = self.normalize_address(walletAddress) authHeaders['POLY_ADDRESS'] = walletAddress # # Add signature type if provided(defaults to EOA from options) # signatureType = self.get_signature_type(params) # eoaSignatureType = self.safe_integer(self.safe_dict(self.options, 'signatureTypes', {}), 'EOA', 0) # if signatureType != eoaSignatureType: # authHeaders['POLY_SIGNATURE_TYPE'] = str(signatureType) # } # # Add chain ID(defaults to 137 for Polygon mainnet, 80001 for testnet) # # chain_id: 137 = Polygon mainnet(default), 80001 = Polygon Mumbai testnet # chainId = self.safe_integer(self.options, 'chainId', 137) # authHeaders['POLY_CHAIN_ID'] = str(chainId) return authHeaders def build_private_request(self, baseUrl: str, pathWithParams: str, method: str, queryParams: dict, body: str = None, headers: dict = None) -> dict: """ Builds a private(authenticated) request with L2 authentication :param str baseUrl: API base URL :param str pathWithParams: path with parameters :param str method: HTTP method :param dict queryParams: query parameters :param str [body]: request body :param dict [headers]: existing headers :returns dict: request object with url, method, body, and headers """ # Ensure privateKey is set if self.privateKey is None: raise ArgumentsRequired(self.id + ' requires privateKey for authenticated requests') # Get API credentials - self will raise if credentials not generated # For lazy generation, ensureApiCredentials() should be called before self creds = self.get_api_credentials() timestamp = str(self.nonce()) # Serialize body deterministically if it's an object(matching py-clob-client) # Use json.dumpswhich produces compact JSON by default(no spaces) # This matches: json.dumps(body, separators=(",", ":"), ensure_ascii=False) serializedBody: str = None if body is not None: if isinstance(body, dict): # Deterministic JSON: compact format(no spaces) serializedBody = json.dumps(body) else: serializedBody = str(body) elif queryParams and (method == 'POST' or method == 'PUT' or method == 'DELETE'): # If body is None but we have queryParams for POST/PUT/DELETE, serialize them serializedBody = json.dumps(queryParams) # Build request path and payload using the serialized body pathAndPayload = self.build_request_path_and_payload(pathWithParams, method, queryParams, serializedBody) requestPath = pathAndPayload['requestPath'] requestUrl = pathAndPayload['url'] # Use the serialized body for the actual request(exact string that will be sent) finalBody = serializedBody is not serializedBody if None else pathAndPayload['body'] privateUrl = baseUrl + requestUrl # Create Level 2 signature: for GET requests, do NOT include query params in signature # For POST/PUT/DELETE, include the serialized body(not query params) # This matches py-clob-client: signature = timestamp + method + requestPath [+ body for non-GET] bodyForSignature = None if (method == 'GET') else serializedBody signature = self.create_level2_signature(timestamp, method, requestPath, bodyForSignature, creds['secret']) # Create Level 2 headers authHeaders = self.create_level2_headers(creds['apiKey'], timestamp, signature, creds['password']) # Merge with existing headers headers = self.build_default_headers(method, headers) headers = self.extend(headers, authHeaders) return {'url': privateUrl, 'method': method, 'body': finalBody, 'headers': headers} def sign(self, path, api: Any = [ 'clob', 'public' ], method='GET', params={}, headers=None, body=None): """ Signs a request for authenticated endpoints https://docs.polymarket.com/developers/CLOB/authentication :param str path: API endpoint path :param str api: API type('public' or 'private') :param str method: HTTP method('GET', 'POST', etc.) :param dict params: Request parameters :param dict headers: Request headers :param str body: Request body :returns dict: Signed request with url, method, body, and headers """ # Get API base URL baseUrl = self.get_api_base_url(params) # Build path with parameters pathWithParams = self.implode_params(path, params) query = self.omit(params, self.extract_params(path)) # Remove api_type from query params's not part of the actual API request queryParams = self.omit(query, ['api_type']) # For public endpoints, no authentication needed # api is always an array like ['gamma', 'public'] or ['clob', 'private'] # The second element is the access level(public/private) accessLevel = self.safe_string(api, 1, 'public') if accessLevel == 'public': return self.build_public_request(baseUrl, pathWithParams, method, queryParams, body, headers) # For private endpoints, use L2 authentication return self.build_private_request(baseUrl, pathWithParams, method, queryParams, body, headers) def handle_errors(self, code: int, reason: str, url: str, method: str, headers: dict, body: str, response: Any, requestHeaders: Any, requestBody: Any): if response is None: return None # Polymarket API errors if code >= 400: # Explicitly check for 401(Unauthorized) and raise AuthenticationError if code == 401: authFeedback = self.id + ' ' + method + ' ' + url + ' 401 ' + reason + ' ' + body raise AuthenticationError(authFeedback) # Try to parse error message from response first(can be JSON or text) # Check error message BEFORE status code to catch specific errors like "Order not found" # that may return 400 status but should raise OrderNotFound instead of BadRequest errorMessage = None errorData = None try: if isinstance(response, str): errorMessage = response elif isinstance(response, dict): errorMessage = self.safe_string(response, 'error') if errorMessage is None: errorMessage = self.safe_string(response, 'message') if errorMessage is None: # If no error/message field, use the whole response data errorData = response except Exception as e: errorMessage = body feedback = self.id + ' ' + (errorMessage or body) if errorMessage is not None: # Try exact match first(e.g., "Order not found" -> OrderNotFound) self.throw_exactly_matched_exception(self.exceptions['exact'], errorMessage, feedback) # Then try broad match self.throw_broadly_matched_exception(self.exceptions['broad'], errorMessage, feedback) # If no match, fall through to status code check # Check HTTP status code(use throwExactlyMatchedException for proper type handling) # This handles cases where no specific error message is found in the response codeAsString = str(code) statusCodeFeedback = self.id + ' ' + method + ' ' + url + ' ' + codeAsString + ' ' + reason + ' ' + body self.throw_exactly_matched_exception(self.exceptions['exact'], codeAsString, statusCodeFeedback) # If we reach here, no exception was thrown, so raise a generic error if errorData is not None: raise ExchangeError(self.id + ' ' + self.json(errorData)) else: raise ExchangeError(feedback) return None ================================================ FILE: Trading/Exchange/polymarket/ccxt/polymarket_pro.py ================================================ # -*- coding: utf-8 -*- # PLEASE DO NOT EDIT THIS FILE, IT IS GENERATED AND WILL BE OVERWRITTEN: # https://github.com/ccxt/ccxt/blob/master/CONTRIBUTING.md#how-to-contribute-code from .polymarket_async import polymarket from ccxt.async_support.base.ws.cache import ArrayCache, ArrayCacheBySymbolById from ccxt.base.types import Any, Int, Order, OrderBook, Str, Ticker, Trade from ccxt.async_support.base.ws.client import Client from typing import List from ccxt.base.errors import ArgumentsRequired class polymarket(polymarket): def describe(self) -> Any: return self.deep_extend(super(polymarket, self).describe(), { 'has': { 'ws': True, 'watchBalance': False, 'watchTicker': True, 'watchTickers': False, 'watchTrades': True, 'watchTradesForSymbols': False, 'watchMyTrades': True, 'watchOrders': True, 'watchOrderBook': True, 'watchOHLCV': False, }, 'urls': { 'api': { 'ws': { 'market': 'wss://ws-subscriptions-clob.polymarket.com/ws/market', 'user': 'wss://ws-subscriptions-clob.polymarket.com/ws/user', 'liveData': 'wss://ws-live-data.polymarket.com', }, }, }, 'options': { 'watchOrderBook': { 'channel': 'book', }, }, 'streaming': { }, }) async def watch_order_book(self, symbol: str, limit: Int = None, params={}) -> OrderBook: """ watches information on open orders with bid(buy) and ask(sell) prices, volumes and other data :param str symbol: unified symbol of the market to fetch the order book for :param int [limit]: the maximum amount of order book entries to return :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_id]: the asset ID for the specific outcome(required if market has multiple outcomes) :returns dict: A dictionary of `order book structures ` indexed by market symbols """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) assetId = self.safe_string_2(params, 'asset_id', 'token_id') # Support both for backward compatibility # If asset_id not provided, use first token ID from market if assetId is None: if isinstance(clobTokenIds, list) and len(clobTokenIds) > 0: assetId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' watchOrderBook() requires asset_id parameter when market has multiple outcomes') url = self.urls['api']['ws']['market'] messageHash = 'orderbook:' + symbol + ':' + assetId request: dict = { 'type': 'MARKET', 'assets_ids': [assetId], } subscription: dict = { 'symbol': symbol, 'asset_id': assetId, } orderbook = await self.watch(url, messageHash, request, messageHash, subscription) return orderbook.limit(limit) async def watch_trades(self, symbol: str, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ get the list of most recent trades for a particular symbol :param str symbol: unified symbol of the market to fetch trades for :param int [since]: timestamp in ms of the earliest trade to fetch :param int [limit]: the maximum amount of trades to fetch :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_id]: the asset ID for the specific outcome(required if market has multiple outcomes) :returns dict[]: a list of `trade structures ` """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) assetId = self.safe_string_2(params, 'asset_id', 'token_id') # Support both for backward compatibility # If asset_id not provided, use first token ID from market if assetId is None: if isinstance(clobTokenIds, list) and len(clobTokenIds) > 0: assetId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' watchTrades() requires asset_id parameter when market has multiple outcomes') url = self.urls['api']['ws']['market'] messageHash = 'trades:' + symbol + ':' + assetId request: dict = { 'type': 'MARKET', 'assets_ids': [assetId], } subscription: dict = { 'symbol': symbol, 'asset_id': assetId, } trades = await self.watch(url, messageHash, request, messageHash, subscription) if self.newUpdates: limit = trades.getLimit(symbol, limit) return self.filter_by_symbol_since_limit(trades, symbol, since, limit, True) async def watch_ticker(self, symbol: str, params={}) -> Ticker: """ watches a price ticker, a statistical calculation with the information calculated over the past 24 hours for a specific market :param str symbol: unified symbol of the market to fetch the ticker for :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_id]: the asset ID for the specific outcome(required if market has multiple outcomes) :returns dict: a `ticker structure ` """ await self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) assetId = self.safe_string_2(params, 'asset_id', 'token_id') # Support both for backward compatibility # If asset_id not provided, use first token ID from market if assetId is None: if isinstance(clobTokenIds, list) and len(clobTokenIds) > 0: assetId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' watchTicker() requires asset_id parameter when market has multiple outcomes') url = self.urls['api']['ws']['market'] messageHash = 'ticker:' + symbol + ':' + assetId request: dict = { 'type': 'MARKET', 'assets_ids': [assetId], } subscription: dict = { 'symbol': symbol, 'asset_id': assetId, } return await self.watch(url, messageHash, request, messageHash, subscription) async def watch_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Order]: """ watches information on an order made by the user :param str [symbol]: unified symbol of the market the order was made in :param int [since]: timestamp in ms of the earliest order to watch :param int [limit]: the maximum amount of orders to watch :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: An `order structure ` """ await self.authenticate(params) messageHash = 'orders' url = self.urls['api']['ws']['user'] request: dict = { 'type': 'USER', } if symbol is not None: symbol = self.safe_symbol(symbol) messageHash = messageHash + ':' + symbol market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) if conditionId is not None: request['markets'] = [conditionId] orders = await self.watch(url, messageHash, request, messageHash) if self.newUpdates: limit = orders.getLimit(symbol, limit) return self.filter_by_symbol_since_limit(orders, symbol, since, limit, True) async def watch_my_trades(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ get the list of trades associated with the user :param str [symbol]: unified symbol of the market to fetch trades for :param int [since]: timestamp in ms of the earliest trade to fetch :param int [limit]: the maximum amount of trades to fetch :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `trade structures ` """ await self.authenticate(params) messageHash = 'myTrades' url = self.urls['api']['ws']['user'] request: dict = { 'type': 'USER', } if symbol is not None: symbol = self.safe_symbol(symbol) messageHash = messageHash + ':' + symbol market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) if conditionId is not None: request['markets'] = [conditionId] trades = await self.watch(url, messageHash, request, messageHash) if self.newUpdates: limit = trades.getLimit(symbol, limit) return self.filter_by_symbol_since_limit(trades, symbol, since, limit, True) def handle_order_book(self, client: Client, message): # # Market websocket order book event: # { # "event_type": "book", # "asset_id": "0x...", # "bids": [[price, size], ...], # "asks": [[price, size], ...], # "timestamp": 1234567890 # } # # Or array of events: # [{...}, {...}] # messages = [] if isinstance(message, list): messages = message else: messages = [message] for i in range(0, len(messages)): msg = messages[i] eventType = self.safe_string(msg, 'event_type') if eventType != 'book': continue assetId = self.safe_string(msg, 'asset_id') # Find symbol and asset_id from subscriptions symbol = None subscriptionAssetId = None subscriptionKeys = list(client.subscriptions.keys()) for j in range(0, len(subscriptionKeys)): subscribeHash = subscriptionKeys[j] subscription = client.subscriptions[subscribeHash] if subscription != None: subAssetId = self.safe_string_2(subscription, 'asset_id', 'token_id') # Support both for backward compatibility if subAssetId == assetId: symbol = self.safe_string(subscription, 'symbol') subscriptionAssetId = subAssetId break if symbol is None: # Try to resolve from asset_id market = self.safe_market(assetId) symbol = market['symbol'] subscriptionAssetId = assetId messageHash = 'orderbook:' + symbol + ':' + subscriptionAssetId if not (symbol in self.orderbooks): self.orderbooks[symbol] = self.order_book({}) orderbook = self.orderbooks[symbol] # Polymarket docs use `buys`/`sells` with OrderSummary objects, but some payloads use `bids`/`asks` rawBids = self.safe_value_2(msg, 'bids', 'buys', []) rawAsks = self.safe_value_2(msg, 'asks', 'sells', []) bids = [] bidLevels = self.to_array(rawBids) for j in range(0, len(bidLevels)): level = bidLevels[j] if isinstance(level, list): bids.append(level) elif isinstance(level, dict): price = self.safe_string(level, 'price') size = self.safe_string(level, 'size') if price is not None and size is not None: bids.append([price, size]) asks = [] askLevels = self.to_array(rawAsks) for j in range(0, len(askLevels)): level = askLevels[j] if isinstance(level, list): asks.append(level) elif isinstance(level, dict): price = self.safe_string(level, 'price') size = self.safe_string(level, 'size') if price is not None and size is not None: asks.append([price, size]) rawTimestamp = self.safe_integer(msg, 'timestamp') timestamp = None if rawTimestamp is not None: if rawTimestamp > 1000000000000: timestamp = rawTimestamp else: timestamp = rawTimestamp * 1000 datetime = None if timestamp is not None: datetime = self.iso8601(timestamp) snapshot = self.parse_order_book({'bids': bids, 'asks': asks}, symbol, timestamp) orderbook.reset(snapshot) orderbook['symbol'] = symbol orderbook['timestamp'] = timestamp orderbook['datetime'] = datetime client.resolve(orderbook, messageHash) def handle_trades(self, client: Client, message): # # Market websocket trade event: # { # "event_type": "trade", # "asset_id": "0x...", # "trade_id": "0x...", # "price": "0.5", # "size": "100", # "side": "buy", # "timestamp": 1234567890 # } # messages = [] if isinstance(message, list): messages = message else: messages = [message] for i in range(0, len(messages)): msg = messages[i] eventType = self.safe_string(msg, 'event_type') if eventType != 'trade': continue assetId = self.safe_string(msg, 'asset_id') # Find symbol and asset_id from subscriptions symbol = None subscriptionAssetId = None subscriptionKeys = list(client.subscriptions.keys()) for j in range(0, len(subscriptionKeys)): subscribeHash = subscriptionKeys[j] subscription = client.subscriptions[subscribeHash] if isinstance(subscription, dict): subAssetId = self.safe_string_2(subscription, 'asset_id', 'token_id') # Support both for backward compatibility if subAssetId == assetId: symbol = self.safe_string(subscription, 'symbol') subscriptionAssetId = subAssetId break if symbol is None: # Try to resolve from asset_id market = self.safe_market(assetId) symbol = market['symbol'] subscriptionAssetId = assetId messageHash = 'trades:' + symbol + ':' + subscriptionAssetId stored = self.safe_value(self.trades, symbol) if stored is None: limit = self.safe_integer(self.options, 'tradesLimit', 1000) stored = ArrayCache(limit) self.trades[symbol] = stored market = self.market(symbol) trade = self.parse_trade(msg, market) # Normalize WS timestamp(Polymarket typically sends ms timestamps in WS payloads) rawTimestamp = self.safe_integer(msg, 'timestamp') wsTimestamp = None if rawTimestamp is not None: if rawTimestamp > 1000000000000: wsTimestamp = rawTimestamp else: wsTimestamp = rawTimestamp * 1000 if wsTimestamp is not None: trade['timestamp'] = wsTimestamp trade['datetime'] = self.iso8601(wsTimestamp) stored.append(trade) client.resolve(stored, messageHash) def handle_ticker(self, client: Client, message): # # Market websocket ticker events: # { # "event_type": "price_change", # "asset_id": "0x...", # "price": "0.5", # "timestamp": 1234567890 # } # { # "event_type": "last_trade_price", # "asset_id": "0x...", # "price": "0.5", # "timestamp": 1234567890 # } # messages = [] if isinstance(message, list): messages = message else: messages = [message] for i in range(0, len(messages)): msg = messages[i] eventType = self.safe_string(msg, 'event_type') if eventType != 'price_change' and eventType != 'last_trade_price': continue # `last_trade_price` is per-asset, but `price_change` can be a batch containing `price_changes[]`. # Docs: https://docs.polymarket.com/developers/CLOB/websocket/market-channel#price-change-message rawTimestamp = self.safe_integer(msg, 'timestamp') timestamp = None if rawTimestamp is not None: if rawTimestamp > 1000000000000: timestamp = rawTimestamp else: timestamp = rawTimestamp * 1000 priceChanges = self.safe_value(msg, 'price_changes') updates: List[Any] = [] if eventType == 'price_change' and isinstance(priceChanges, list): updates = priceChanges else: updates = [msg] for k in range(0, len(updates)): update = updates[k] assetId = self.safe_string(update, 'asset_id', self.safe_string(msg, 'asset_id')) if assetId is None: continue # Find symbol and asset_id from subscriptions symbol = None subscriptionAssetId = None subscriptionKeys = list(client.subscriptions.keys()) for j in range(0, len(subscriptionKeys)): subscribeHash = subscriptionKeys[j] subscription = client.subscriptions[subscribeHash] if isinstance(subscription, dict): subAssetId = self.safe_string_2(subscription, 'asset_id', 'token_id') # Support both for backward compatibility if subAssetId == assetId: symbol = self.safe_string(subscription, 'symbol') subscriptionAssetId = subAssetId break if symbol is None: # Try to resolve from asset_id market = self.safe_market(assetId) symbol = market['symbol'] subscriptionAssetId = assetId messageHash = 'ticker:' + symbol + ':' + subscriptionAssetId market = self.market(symbol) prev = self.safe_value(self.tickers, symbol, {}) last = self.safe_number(update, 'price', self.safe_number(msg, 'price', self.safe_number(prev, 'last'))) bid = self.safe_number(update, 'best_bid', self.safe_number(prev, 'bid', last)) ask = self.safe_number(update, 'best_ask', self.safe_number(prev, 'ask', last)) info = msg if eventType == 'price_change': info = update datetime = None if timestamp is not None: datetime = self.iso8601(timestamp) ticker: Ticker = { 'symbol': symbol, 'info': info, 'timestamp': timestamp, 'datetime': datetime, 'last': last, 'bid': bid, 'bidVolume': None, 'ask': ask, 'askVolume': None, 'high': None, 'low': None, 'open': None, 'close': last, 'previousClose': None, 'change': None, 'percentage': None, 'average': None, 'baseVolume': None, 'quoteVolume': None, 'vwap': None, 'indexPrice': None, 'markPrice': None, } self.tickers[symbol] = ticker client.resolve(ticker, messageHash) def handle_orders(self, client: Client, message): # # User websocket order event: # { # "event_type": "order", # "order_id": "0x...", # "asset_id": "0x...", # "side": "buy", # "price": "0.5", # "size": "100", # "status": "open", # "timestamp": 1234567890 # } # eventType = self.safe_string(message, 'event_type') if eventType != 'order': return messageHash = 'orders' stored = self.orders if stored is None: limit = self.safe_integer(self.options, 'ordersLimit', 1000) stored = ArrayCacheBySymbolById(limit) self.orders = stored order = self.parse_order(message) rawTimestamp = self.safe_integer(message, 'timestamp') wsTimestamp = None if rawTimestamp is not None: if rawTimestamp > 1000000000000: wsTimestamp = rawTimestamp else: wsTimestamp = rawTimestamp * 1000 if wsTimestamp is not None: order['timestamp'] = wsTimestamp order['datetime'] = self.iso8601(wsTimestamp) orderSymbols: dict = {} orderSymbols[order['symbol']] = True stored.append(order) unique = list(orderSymbols.keys()) for i in range(0, len(unique)): symbol = unique[i] symbolSpecificMessageHash = messageHash + ':' + symbol client.resolve(stored, symbolSpecificMessageHash) client.resolve(stored, messageHash) def handle_my_trades(self, client: Client, message): # # User websocket trade event: # { # "event_type": "trade", # "trade_id": "0x...", # "asset_id": "0x...", # "side": "buy", # "price": "0.5", # "size": "100", # "timestamp": 1234567890 # } # eventType = self.safe_string(message, 'event_type') if eventType != 'trade': return messageHash = 'myTrades' stored = self.myTrades if stored is None: limit = self.safe_integer(self.options, 'tradesLimit', 1000) stored = ArrayCacheBySymbolById(limit) self.myTrades = stored trade = self.parse_trade(message) rawTimestamp = self.safe_integer(message, 'timestamp') wsTimestamp = None if rawTimestamp is not None: if rawTimestamp > 1000000000000: wsTimestamp = rawTimestamp else: wsTimestamp = rawTimestamp * 1000 if wsTimestamp is not None: trade['timestamp'] = wsTimestamp trade['datetime'] = self.iso8601(wsTimestamp) tradeSymbols: dict = {} tradeSymbols[trade['symbol']] = True stored.append(trade) unique = list(tradeSymbols.keys()) uniqueLength = len(unique) if uniqueLength == 0: return for i in range(0, len(unique)): symbol = unique[i] symbolSpecificMessageHash = messageHash + ':' + symbol client.resolve(stored, symbolSpecificMessageHash) client.resolve(stored, messageHash) def handle_message(self, client: Client, message): # # Market websocket messages can be: # - Single event object: {"event_type": "book", ...} # - Array of events: [{"event_type": "book", ...}, ...] # - Ready event: {"event": "ready"} or similar(check Python code) # # User websocket messages: # - Single event object: {"event_type": "order", ...} # # Check for ready event first(Polymarket may send self) event = self.safe_string(message, 'event') if event == 'ready' or event == 'connected': # Connection ready - subscriptions are sent automatically by base watch() method return if isinstance(message, list): # Handle array of events(market websocket) self.handle_market_events(client, message) else: eventType = self.safe_string(message, 'event_type') url = client.url # Determine which websocket based on URL if url.find('/ws/market') >= 0: # Market websocket self.handle_market_event(client, message, eventType) elif url.find('/ws/user') >= 0: # User websocket self.handle_user_event(client, message, eventType) elif url.find('ws-live-data') >= 0: # Live data websocket - not implemented yet if self.verbose: self.log('Live data websocket message:', message) def handle_market_events(self, client: Client, messages: List[Any]): # Handle array of market events for i in range(0, len(messages)): msg = messages[i] eventType = self.safe_string(msg, 'event_type') self.handle_market_event(client, msg, eventType) def handle_market_event(self, client: Client, message: Any, eventType: str): if eventType == 'book': self.handle_order_book(client, message) elif eventType == 'trade': self.handle_trades(client, message) elif eventType == 'price_change' or eventType == 'last_trade_price': self.handle_ticker(client, message) elif eventType == 'tick_size_change': # Tick size change - can be used to update ticker if self.verbose: self.log('Tick size change event:', message) else: # Unknown event type, log but don't error if self.verbose: self.log('Unknown market websocket event type:', eventType, message) def handle_user_event(self, client: Client, message: Any, eventType: str): if eventType == 'order': self.handle_orders(client, message) elif eventType == 'trade': self.handle_my_trades(client, message) else: # Unknown event type, log but don't error if self.verbose: self.log('Unknown user websocket event type:', eventType, message) async def authenticate(self, params={}): url = self.urls['api']['ws']['user'] client = self.client(url) messageHash = 'authenticated' future = self.safe_value(client.subscriptions, messageHash) if future is None: # Get API credentials creds = await self.ensureApiCredentials(params) # Build auth payload matching Python implementation # auth=creds.model_dump(by_alias=True) in Python becomes: auth: dict = { 'apiKey': creds['apiKey'], 'secret': creds['secret'], 'passphrase': creds['passphrase'], } request: dict = { 'auth': auth, 'type': 'USER', } future = await self.watch(url, messageHash, request, messageHash) client.subscriptions[messageHash] = future return future async def watch(self, url: str, messageHash: str, message=None, subscribeHash=None, subscription=None): client = self.client(url) if subscribeHash is None: subscribeHash = messageHash # Store subscription info for market websocket to use in handleMessage if subscription is not None and url.find('/ws/market') >= 0: # Store subscription separately so we can look it up by asset_id if not (subscribeHash in client.subscriptions): client.subscriptions[subscribeHash] = subscription return await super(polymarket, self).watch(url, messageHash, message, subscribeHash, subscription) def on_connected(self, client: Client): # Called when websocket connection is established # The base watch() method will send the message automatically # But for Polymarket, we may need to wait for a "ready" event # For now, the base class handle it super(polymarket, self).on_connected(client) ================================================ FILE: Trading/Exchange/polymarket/ccxt/polymarket_sync.py ================================================ # -*- coding: utf-8 -*- # PLEASE DO NOT EDIT THIS FILE, IT IS GENERATED AND WILL BE OVERWRITTEN: # https://github.com/ccxt/ccxt/blob/master/CONTRIBUTING.md#how-to-contribute-code from ccxt.base.exchange import Exchange from .polymarket_abstract import ImplicitAPI import hashlib import math import json import numbers from ccxt.base.types import Any, Int, Market, MarketType, Num, Order, OrderBook, OrderRequest, OrderSide, OrderType, Str, Strings, Ticker, Tickers, OrderBooks, Trade, TradingFeeInterface from typing import List from ccxt.base.errors import ExchangeError from ccxt.base.errors import AuthenticationError from ccxt.base.errors import PermissionDenied from ccxt.base.errors import ArgumentsRequired from ccxt.base.errors import BadRequest from ccxt.base.errors import InsufficientFunds from ccxt.base.errors import InvalidOrder from ccxt.base.errors import OrderNotFound from ccxt.base.errors import NetworkError from ccxt.base.errors import RateLimitExceeded from ccxt.base.errors import ExchangeNotAvailable from ccxt.base.errors import OnMaintenance from ccxt.base.decimal_to_precision import ROUND from ccxt.base.decimal_to_precision import TICK_SIZE from ccxt.base.precise import Precise class polymarket(Exchange, ImplicitAPI): def describe(self) -> Any: return self.deep_extend(super(polymarket, self).describe(), { 'id': 'polymarket', 'name': 'Polymarket', 'countries': ['US'], 'version': '1', # Rate limits are enforced using Cloudflare's throttling system # Requests over the limit are throttled/delayed rather than rejected # See https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation formula: cost = (1000 / rateLimit) * 60 / requests_per_minute # With rateLimit = 50ms(20 req/s = 1200 req/min), base cost = 1.0 # General limits: # - General Rate Limiting: 5000 requests / 10s(500 req/s = 30000 req/min) => cost = 0.04 # - CLOB(General): 5000 requests / 10s(500 req/s = 30000 req/min) => cost = 0.04 # - GAMMA(General): 750 requests / 10s(75 req/s = 4500 req/min) => cost = 0.267 # - Data API(General): 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # Setting to 50ms(20 req/s) to match the most restrictive general limit(Data API) # Specific endpoint costs are calculated relative to self base rateLimit 'rateLimit': 50, # 20 requests per second(matches Data API general limit) 'certified': False, 'pro': True, 'requiredCredentials': { 'apiKey': False, 'secret': False, 'walletAddress': True, 'privateKey': True, }, 'has': { 'CORS': None, 'spot': False, 'margin': False, 'swap': False, 'future': False, 'option': True, 'addMargin': False, 'cancelOrder': True, 'cancelOrders': True, 'createDepositAddress': True, # TODO with https://docs.polymarket.com/developers/misc-endpoints/bridge-deposit 'createMarketBuyOrderWithCost': False, 'createMarketOrder': True, 'createMarketOrderWithCost': False, 'createMarketSellOrderWithCost': False, 'createOrder': True, 'createOrders': True, 'createStopLimitOrder': False, 'createStopMarketOrder': False, 'createStopOrder': False, 'editOrder': False, 'fetchBalance': True, 'fetchBorrowInterest': False, 'fetchBorrowRateHistories': False, 'fetchBorrowRateHistory': False, 'fetchClosedOrders': False, 'fetchCrossBorrowRate': False, 'fetchCrossBorrowRates': False, 'fetchCurrencies': False, 'fetchDepositAddress': False, 'fetchDepositAddresses': True, # TODO with https://docs.polymarket.com/developers/misc-endpoints/bridge-supported-assets 'fetchDepositAddressesByNetwork': True, # TODO with https://docs.polymarket.com/developers/misc-endpoints/bridge-supported-assets 'fetchDeposits': False, 'fetchFundingHistory': False, 'fetchFundingRate': False, 'fetchFundingRateHistory': False, 'fetchFundingRates': False, 'fetchIndexOHLCV': False, 'fetchIsolatedBorrowRate': False, 'fetchIsolatedBorrowRates': False, 'fetchLedger': False, 'fetchLedgerEntry': False, 'fetchLeverageTiers': False, 'fetchMarkets': True, 'fetchMarkOHLCV': False, 'fetchMyTrades': True, 'fetchOHLCV': True, 'fetchOpenInterest': True, 'fetchOpenInterestHistory': False, 'fetchOpenOrders': True, 'fetchOrder': True, 'fetchOrderBook': True, 'fetchOrderBooks': True, 'fetchOrders': True, 'fetchPositionMode': False, 'fetchPremiumIndexOHLCV': False, 'fetchStatus': True, 'fetchTicker': True, 'fetchTickers': True, 'fetchTime': True, 'fetchTrades': True, 'fetchTradingFee': True, 'fetchTradingFees': False, 'fetchWithdrawals': False, 'setLeverage': False, 'setMarginMode': False, 'transfer': False, 'withdraw': False, }, 'urls': { 'logo': 'https://polymarket.com/favicon.ico', 'api': { 'gamma': 'https://gamma-api.polymarket.com', 'clob': 'https://clob.polymarket.com', # Can be overridden with options.clobHost 'data': 'https://data-api.polymarket.com', 'bridge': 'https://bridge.polymarket.com', 'ws': 'wss://ws-subscriptions-clob.polymarket.com/ws/', # CLOB WebSocket for subscriptions 'rtds': 'wss://ws-live-data.polymarket.com', # Real Time Data Socket for crypto prices and comments }, 'test': {}, # TODO if exists 'www': 'https://polymarket.com', 'doc': [ 'https://docs.polymarket.com', ], 'fees': 'https://docs.polymarket.com/developers/CLOB/introduction', }, 'api': { # GAMMA API: https://gamma-api.polymarket.com # Rate limits: https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation: cost = (1000 / 50) * 60 / requests_per_minute = 1200 / requests_per_minute # - GAMMA(General): 750 requests / 10s(75 req/s = 4500 req/min) => cost = 0.267 # - GAMMA Get Comments: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA /events: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA /markets: 125 requests / 10s(12.5 req/s = 750 req/min) => cost = 1.6 # - GAMMA /markets /events listing: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA Tags: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - GAMMA Search: 300 requests / 10s(30 req/s = 1800 req/min) => cost = 0.667 'gamma': { 'public': { 'get': { # Market endpoints 'markets': 1.6, # GET /markets - used by fetchMarkets(125 req/10s = 750 req/min) 'markets/{id}': 0.267, # GET /markets/{id} - used by gammaPublicGetMarketsId(general limit) 'markets/{id}/tags': 2.0, # GET /markets/{id}/tags - used by gammaPublicGetMarketsIdTags(100 req/10s = 600 req/min) 'markets/slug/{slug}': 0.267, # GET /markets/slug/{slug} - used by gammaPublicGetMarketsSlugSlug(general limit) # Event endpoints 'events': 2.0, # GET /events - used by gammaPublicGetEvents(100 req/10s = 600 req/min) 'events/{id}': 0.267, # GET /events/{id} - used by gammaPublicGetEventsId(general limit) # Series endpoints 'series': 0.267, # GET /series - used by gammaPublicGetSeries(general limit) 'series/{id}': 0.267, # GET /series/{id} - used by gammaPublicGetSeriesId(general limit) # Search endpoints 'search': 0.667, # GET /search - used by gammaPublicGetSearch(300 req/10s = 1800 req/min) # Comment endpoints 'comments': 2.0, # GET /comments - used by gammaPublicGetComments(100 req/10s = 600 req/min) 'comments/{id}': 0.267, # GET /comments/{id} - used by gammaPublicGetCommentsId(general limit) # Sports endpoints 'sports': 0.267, # GET /sports - used by gammaPublicGetSports(general limit) 'sports/{id}': 0.267, # GET /sports/{id} - used by gammaPublicGetSportsId(general limit) }, }, }, # Data-API: https://data-api.polymarket.com # Rate limits: https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation: cost = (1000 / 50) * 60 / requests_per_minute = 1200 / requests_per_minute # - Data API(General): 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - Data API(Alternative): 1200 requests / 1 minute(20 req/s = 1200 req/min) => cost = 1.0 # - Data API /trades: 75 requests / 10s(7.5 req/s = 450 req/min) => cost = 2.67 # - Data API "OK" Endpoint: 10 requests / 10s(1 req/s = 60 req/min) => cost = 20.0 'data': { 'public': { 'get': { # Core endpoints(from Data-API) 'positions': 1.0, # GET /positions - used by dataPublicGetPositions(200 req/10s = 1200 req/min) 'trades': 2.67, # GET /trades - used by dataPublicGetTrades(75 req/10s = 450 req/min) 'activity': 1.0, # GET /activity - used by dataPublicGetActivity(200 req/10s = 1200 req/min) 'holders': 1.0, # GET /holders - used by dataPublicGetHolders(200 req/10s = 1200 req/min) 'value': 1.0, # GET /value - used by dataPublicGetTotalValue(200 req/10s = 1200 req/min) 'closed-positions': 1.0, # GET /closed-positions - used by dataPublicGetClosedPositions(200 req/10s = 1200 req/min) # Misc endpoints(from Data-API) 'traded': 1.0, # GET /traded - used by dataPublicGetTraded(200 req/10s = 1200 req/min) 'oi': 1.0, # GET /oi - used by dataPublicGetOpenInterest(200 req/10s = 1200 req/min) 'live-volume': 1.0, # GET /live-volume - used by dataPublicGetLiveVolume(200 req/10s = 1200 req/min) }, }, }, # Bridge API: https://bridge.polymarket.com # Rate limits: Not explicitly documented, using conservative general rate limits # Assuming similar to Data API: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 'bridge': { 'public': { 'get': { # Bridge endpoints 'supported-assets': 1.0, # GET /supported-assets - used by bridgePublicGetSupportedAssets(assumed 200 req/10s) }, 'post': { # Bridge endpoints 'deposit': 1.0, # POST /deposit - used by bridgePublicPostDeposit(assumed 200 req/10s) }, }, }, # CLOB API: https://clob.polymarket.com # Rate limits: https://docs.polymarket.com/quickstart/introduction/rate-limits # Cost calculation: cost = (1000 / 50) * 60 / requests_per_minute = 1200 / requests_per_minute # General CLOB Endpoints: # - CLOB(General): 5000 requests / 10s(500 req/s = 30000 req/min) => cost = 0.04 # - CLOB GET Balance Allowance: 125 requests / 10s(12.5 req/s = 750 req/min) => cost = 1.6 # - CLOB UPDATE Balance Allowance: 20 requests / 10s(2 req/s = 120 req/min) => cost = 10.0 # CLOB Market Data: # - CLOB /book: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB /books: 80 requests / 10s(8 req/s = 480 req/min) => cost = 2.5 # - CLOB /price: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB /prices: 80 requests / 10s(8 req/s = 480 req/min) => cost = 2.5 # - CLOB /midprice: 200 requests / 10s(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB /midprices: 80 requests / 10s(8 req/s = 480 req/min) => cost = 2.5 # CLOB Ledger Endpoints: # - CLOB Ledger(/trades /orders /notifications /order): 300 requests / 10s(30 req/s = 1800 req/min) => cost = 0.667 # - CLOB Ledger /data/orders: 150 requests / 10s(15 req/s = 900 req/min) => cost = 1.33 # - CLOB Ledger /data/trades: 150 requests / 10s(15 req/s = 900 req/min) => cost = 1.33 # - CLOB /notifications: 125 requests / 10s(12.5 req/s = 750 req/min) => cost = 1.6 # CLOB Markets & Pricing: # - CLOB Price History: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # - CLOB Markets: 250 requests / 10s(25 req/s = 1500 req/min) => cost = 0.8 # - CLOB Market Tick Size: 50 requests / 10s(5 req/s = 300 req/min) => cost = 4.0 # - CLOB markets/0x: 50 requests / 10s(5 req/s = 300 req/min) => cost = 4.0 # - CLOB /markets listing: 100 requests / 10s(10 req/s = 600 req/min) => cost = 2.0 # CLOB Authentication: # - CLOB API Keys: 50 requests / 10s(5 req/s = 300 req/min) => cost = 4.0 # CLOB Trading Endpoints(using sustained limits, not BURST): # - CLOB POST /order: 24000 requests / 10 minutes(40 req/s = 2400 req/min) => cost = 0.5 # - CLOB DELETE /order: 24000 requests / 10 minutes(40 req/s = 2400 req/min) => cost = 0.5 # - CLOB POST /orders: 12000 requests / 10 minutes(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB DELETE /orders: 12000 requests / 10 minutes(20 req/s = 1200 req/min) => cost = 1.0 # - CLOB DELETE /cancel-all: 3000 requests / 10 minutes(5 req/s = 300 req/min) => cost = 4.0 # - CLOB DELETE /cancel-market-orders: 12000 requests / 10 minutes(20 req/s = 1200 req/min) => cost = 1.0 'clob': { 'public': { 'get': { # Order book endpoints 'orderbook': 1.0, # GET /book - used by fetchOrderBook(200 req/10s = 1200 req/min) 'orderbook/{token_id}': 0.04, # Not used(deprecated format, general limit) # Trade endpoints 'market/{condition_id}/trades': 0.04, # Not used(deprecated, use /trades instead, general limit) 'trades': 0.667, # GET /data/trades - used by fetchTrades(300 req/10s = 1800 req/min) # Price history endpoints 'prices-history': 2.0, # GET /prices-history - used by fetchOHLCV(100 req/10s = 600 req/min) # Pricing endpoints 'price': 1.0, # GET /price - available but using POST /prices instead(200 req/10s = 1200 req/min) 'prices': 2.5, # GET /prices - used by fetchTickers(80 req/10s = 480 req/min) # Midpoint endpoints 'midpoint': 1.0, # GET /midpoint - used by fetchTicker(200 req/10s = 1200 req/min) 'midpoints': 2.5, # GET /midpoints - available for fetchTickers enhancement(80 req/10s = 480 req/min) # Spread endpoints 'spread': 0.04, # GET /spread - available for fetchTicker enhancement(general limit) # Last trade price endpoints 'last-trade-price': 0.04, # GET /last-trade-price - available for ticker enhancement(general limit) 'last-trades-prices': 0.04, # GET /last-trades-prices - available for tickers enhancement(general limit) # Utility endpoints '': 4.0, # GET / - health check endpoint used by fetchStatus/clobPublicGetOk(50 req/10s = 300 req/min) 'time': 0.04, # GET /time - used by fetchTime(general limit) 'tick-size': 4.0, # GET /tick-size - used for market precision(50 req/10s = 300 req/min) 'neg-risk': 0.04, # GET /neg-risk - used for market metadata(general limit) 'fee-rate': 0.04, # GET /fee-rate - used by fetchTradingFee(general limit) 'markets': 2.0, # GET /markets - used by fetchMarkets(100 req/10s = 600 req/min) }, 'post': { # Order book endpoints 'books': 2.5, # POST /books - used by fetchOrderBooks(80 req/10s = 480 req/min) # Spread endpoints 'spreads': 0.04, # POST /spreads - used by fetchTickers(optional, general limit) # Pricing endpoints 'prices': 2.5, # POST /prices - used by fetchTicker(80 req/10s = 480 req/min) }, }, 'private': { 'get': { # Order endpoints 'order': 0.667, # GET /data/order/{order_id} - used by fetchOrder(300 req/10s = 1800 req/min) 'orders': 1.33, # GET /data/orders - used by fetchOrders, fetchOpenOrders(150 req/10s = 900 req/min) # Trade endpoints 'trades': 0.667, # GET /data/trades - used by fetchMyTrades(300 req/10s = 1800 req/min) 'builder-trades': 0.667, # GET /builder-trades - used for builder trades(300 req/10s = 1800 req/min) # Notification endpoints 'notifications': 1.6, # GET /notifications - used by getNotifications(125 req/10s = 750 req/min) # Balance endpoints 'balance-allowance': 1.6, # GET /balance-allowance - used by fetchBalance/getBalanceAllowance(125 req/10s = 750 req/min) # Order scoring endpoints 'order-scoring': 0.04, # GET /order-scoring - used by isOrderScoring(general limit) # API credential endpoints(L1 authentication - uses manual URL building) 'auth/derive-api-key': 4.0, # GET /auth/derive-api-key - used by derive_api_key(50 req/10s = 300 req/min) }, 'post': { # Order creation endpoints 'order': 0.5, # POST /order - used by createOrder(24000 req/10min = 2400 req/min sustained) 'orders': 1.0, # POST /orders - used by createOrders(12000 req/10min = 1200 req/min sustained) # Order scoring endpoints 'orders-scoring': 0.04, # POST /orders-scoring - used by areOrdersScoring(general limit) # API credential endpoints 'auth/api-key': 4.0, # POST /auth/api-key - used by create_or_derive_api_creds(50 req/10s = 300 req/min) }, 'delete': { # Order cancellation endpoints 'order': 0.5, # DELETE /order - used by cancelOrder(24000 req/10min = 2400 req/min sustained) 'orders': 1.0, # DELETE /orders - used by cancelOrders(12000 req/10min = 1200 req/min sustained) 'cancel-all': 4.0, # DELETE /cancel-all - used by cancelAllOrders(3000 req/10min = 300 req/min sustained) 'cancel-market-orders': 1.0, # DELETE /cancel-market-orders - used for canceling market orders(12000 req/10min = 1200 req/min sustained) # Notification endpoints 'notifications': 0.04, # DELETE /notifications - used by dropNotifications(general limit) }, 'put': { # Balance endpoints 'balance-allowance': 10.0, # PUT /balance-allowance - used by updateBalanceAllowance(20 req/10s = 120 req/min) }, }, }, }, 'timeframes': { '1m': '1m', '1h': '1h', '6h': '6h', '1d': '1d', '1w': '1w', }, 'fees': { 'trading': { 'tierBased': False, 'percentage': True, 'taker': self.parse_number('0.02'), # 2% taker fee(approximate) 'maker': self.parse_number('0.02'), # 2% maker fee(approximate) }, }, 'options': { 'fetchMarkets': { 'active': True, # only fetch active markets by default 'closed': False, 'archived': False, }, 'funder': None, # Address that holds funds(walletAddress, required for proxy wallets like email/Magic wallets) 'proxyWallet': None, # Proxy wallet address for Data-API endpoints(defaults to funder/walletAddress if not set) 'builderWallet': None, # Builder wallet address(defaults to funder/walletAddress if not set) 'signatureTypes': { # https://docs.polymarket.com/developers/CLOB/orders/orders#signature-types 'EOA': 0, # EIP712 signature signed by an EOA 'POLY_PROXY': 1, # EIP712 signatures signed by a signer associated with funding Polymarket proxy wallet 'POLY_GNOSIS_SAFE': 2, # EIP712 signatures signed by a signer associated with funding Polymarket gnosis safe wallet }, 'side': None, # Order side: 'BUY' or 'SELL'(default: None, must be provided) 'sides': { 'BUY': 0, # Buy side(maker gives USDC, wants tokens) 'SELL': 1, # Sell side(maker gives tokens, wants USDC) }, 'chainId': 137, # Chain ID: 137 = Polygon mainnet(default), 80001 = Polygon Mumbai testnet 'chainName': 'polygon-mainnet', # Chain name: 'polygon-mainnet'(default), 'polygon-mumbai'(testnet) 'sandboxMode': False, # Enable sandbox/testnet mode(uses Polygon Mumbai testnet) 'clobHost': None, # Custom CLOB API endpoint(defaults to https://clob.polymarket.com) 'defaultCollateral': 'USDC', # Default collateral currency 'defaultExpirationDays': 30, # Default expiration in days(default: 30 days from now) 'defaultFeeRateBps': 200, # Default fee rate fallback in basis points(default: 200 bps = 2%) 'defaultTickSize': '0.01', # Default tick size for rounding config(default: 0.01 = 2 decimal places for price, 2 for size, 4 for amount) 'marketOrderQuoteDecimals': 2, # Max decimal places for quote currency(USDC) in market orders(default: 2) 'marketOrderBaseDecimals': 4, # Max decimal places for base currency(tokens) in market orders(default: 4) 'roundingBufferDecimals': 4, # Additional decimal places buffer for rounding up before final rounding down(default: 4) # Constants matching clob-client # See https://github.com/Polymarket/clob-client/blob/main/src/signing/constants.ts # See https://github.com/Polymarket/clob-client/blob/main/src/constants.ts 'clobDomainName': 'ClobAuthDomain', 'clobVersion': '1', 'msgToSign': 'This message attests that I control the given wallet', 'initialCursor': 'MA==', # Base64 encoded empty string, matches clob-client INITIAL_CURSOR 'endCursor': 'LTE=', # Sentinel value indicating end of pagination 'defaultTokenId': None, # Default token ID for conditional tokens # Constants matching py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py 'zeroAddress': '0x0000000000000000000000000000000000000000', # Zero address for open orders(taker) # EIP-712 domain constants matching clob-order-utils # See https://github.com/Polymarket/clob-order-utils/blob/main/src/exchange.order.const.ts 'orderDomainName': 'Polymarket CTF Exchange', # EIP-712 domain name for orders(PROTOCOL_NAME) 'orderDomainVersion': '1', # EIP-712 domain version for orders(PROTOCOL_VERSION) # Contract addresses for all networks # See https://github.com/Polymarket/clob-client/blob/main/src/config.ts 'contracts': { # Polygon Amoy testnet(chainId: 80001) '80001': { 'exchange': '0xdFE02Eb6733538f8Ea35D585af8DE5958AD99E40', 'negRiskAdapter': '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', 'negRiskExchange': '0xC5d563A36AE78145C45a50134d48A1215220f80a', 'collateral': '0x9c4e1703476e875070ee25b56a58b008cfb8fa78', 'conditionalTokens': '0x69308FB512518e39F9b16112fA8d994F4e2Bf8bB', }, # Polygon mainnet(chainId: 137) '137': { 'exchange': '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E', 'negRiskAdapter': '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', 'negRiskExchange': '0xC5d563A36AE78145C45a50134d48A1215220f80a', 'collateral': '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', 'conditionalTokens': '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045', }, }, }, 'exceptions': { 'exact': { # HTTP status codes '400': BadRequest, # Bad Request - Invalid request parameters '401': AuthenticationError, # Unauthorized - Invalid or missing authentication '403': PermissionDenied, # Forbidden - Insufficient permissions '404': ExchangeError, # Not Found - Resource not found '429': RateLimitExceeded, # Too Many Requests - Rate limit exceeded '500': ExchangeError, # Internal Server Error '502': ExchangeError, # Bad Gateway '503': OnMaintenance, # Service Unavailable - Service temporarily unavailable '504': NetworkError, # Gateway Timeout # Common error messages(will be matched against error/message fields in response) 'Invalid signature': AuthenticationError, # Invalid signature in request 'Invalid API key': AuthenticationError, # Invalid or missing API key 'Invalid timestamp': AuthenticationError, # Invalid timestamp in request 'Signature expired': AuthenticationError, # Request timestamp is too old 'Unauthorized': AuthenticationError, # Authentication failed 'Forbidden': PermissionDenied, # Access denied 'Rate limit exceeded': RateLimitExceeded, # Rate limit exceeded 'Too many requests': RateLimitExceeded, # Too many requests 'Invalid order': InvalidOrder, # Order validation failed 'Invalid orderID': OrderNotFound, # Order does not exist 'Order not found': OrderNotFound, # Order does not exist 'Insufficient funds': InsufficientFunds, # Insufficient balance 'Insufficient balance': InsufficientFunds, # Insufficient balance 'Invalid market': BadRequest, # Invalid market/symbol 'Invalid symbol': BadRequest, # Invalid symbol 'Market not found': BadRequest, # Market does not exist 'Service unavailable': ExchangeNotAvailable, # Service temporarily unavailable 'Maintenance': OnMaintenance, # Service under maintenance }, 'broad': { 'authentication': AuthenticationError, # Any authentication-related error 'authorization': PermissionDenied, # Any authorization-related error 'rate limit': RateLimitExceeded, # Any rate limit error 'invalid order': InvalidOrder, # Any order validation error 'insufficient': InsufficientFunds, # Any insufficient funds/balance error 'not found': ExchangeError, # Any not found error 'timeout': NetworkError, # Any timeout error 'network': NetworkError, # Any network-related error 'maintenance': OnMaintenance, # Any maintenance-related error }, }, }) def get_signature_type(self, params={}): """ Helper method to get signature type from params or options with fallback to constants :param dict [params]: parameters that may contain signatureType or signature_type :returns number|None: signature type value """ signatureTypes = self.safe_dict(self.options, 'signatureTypes', {}) eoaSignatureType = self.safe_integer(signatureTypes, 'EOA') polyProxySignatureType = self.safe_integer(signatureTypes, 'POLY_PROXY') polyGnosisSafeSignatureType = self.safe_integer(signatureTypes, 'POLY_GNOSIS_SAFE') # Note: POLY_GNOSIS_SAFE is not supported for now proxyWalletAddress = self.get_proxy_wallet_address() mainWalletAddress = self.get_main_wallet_address() if proxyWalletAddress != mainWalletAddress: return polyProxySignatureType return eoaSignatureType def get_side(self, sideString: str, params={}): """ Helper method to get side from params or options with fallback to constants Converts BUY/SELL string to integer: BUY = 0, SELL = 1(matches UtilsBuy/UtilsSell from py-order-utils) :param str sideString: side('BUY' or 'SELL') :param dict [params]: parameters that may contain side or side_int :returns number: side(0 for BUY, 1 for SELL) """ # Check if side_int is provided directly in params sideInt = self.safe_integer(params, 'sideInt') or self.safe_integer(params, 'side_int') if sideInt is not None: return sideInt # Get sides enum from options sides = self.safe_dict(self.options, 'sides', {}) buySide = self.safe_integer(sides, 'BUY', 0) sellSide = self.safe_integer(sides, 'SELL', 1) # Convert side string to integer sideUpper = sideString.upper() sideValue = sellSide # Default to SELL if sideUpper == 'BUY': sideValue = buySide return sideValue def fetch_markets(self, params={}) -> List[Market]: """ retrieves data on all markets for polymarket https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide#3-fetch-all-active-markets :param dict [params]: extra parameters specific to the exchange API endpoint :param boolean [params.active]: fetch active markets only(default: True) :param boolean [params.closed]: fetch closed markets :returns dict[]: an array of objects representing market data """ limit = 500 options = self.safe_dict(self.options, 'fetchMarkets', {}) request: dict = self.extend({ 'order': 'id', 'ascending': False, 'limit': limit, 'offset': 0, }, params) active = self.safe_bool(options, 'active', True) if self.safe_value(params, 'closed') is None: request['closed'] = not active offset = self.safe_integer(request, 'offset', 0) markets: List[Any] = [] while(True): pageRequest = self.extend(request, {'offset': offset}) response = self.gamma_public_get_markets(pageRequest) page = self.safe_list(response, 'data', response) or [] markets = self.array_concat(markets, page) if len(page) < limit: break offset += limit filtered = [] for i in range(0, len(markets)): market = markets[i] id = self.safe_string(market, 'id') conditionId = self.safe_string(market, 'conditionId') or self.safe_string(market, 'condition_id') if id is None and conditionId is None: continue filtered.append(market) return self.parse_markets(filtered) def parse_market(self, market: dict) -> Market: # Schema uses 'conditionId'(camelCase) conditionId = self.safe_string(market, 'conditionId') question = self.safe_string(market, 'question') # Schema uses 'questionID'(camelCase) questionId = self.safe_string(market, 'questionID') # Schema uses 'slug'(camelCase) slug = self.safe_string(market, 'slug') active = self.safe_bool(market, 'active', False) closed = self.safe_bool(market, 'closed', False) archived = self.safe_bool(market, 'archived', False) outcomes = [] outcomePrices = [] outcomesStr = self.safe_string(market, 'outcomes') if outcomesStr is not None: parsedOutcomes = None try: parsedOutcomes = json.loads(outcomesStr) except Exception as e: parsedOutcomes = None if parsedOutcomes is not None and len(parsedOutcomes) is not None: for i in range(0, len(parsedOutcomes)): outcomes.append(parsedOutcomes[i]) else: outcomesArray = outcomesStr.split(',') for i in range(0, len(outcomesArray)): v = outcomesArray[i].strip() if v != '': outcomes.append(v) outcomePricesStr = self.safe_string(market, 'outcomePrices') if outcomePricesStr is not None: parsedPrices = None try: parsedPrices = json.loads(outcomePricesStr) except Exception as e: parsedPrices = None if parsedPrices is not None and len(parsedPrices) is not None: for i in range(0, len(parsedPrices)): outcomePrices.append(self.parse_number(parsedPrices[i])) else: pricesArray = outcomePricesStr.split(',') for i in range(0, len(pricesArray)): v = pricesArray[i].strip() if v != '': outcomePrices.append(self.parse_number(v)) # Use slug symbol if available baseId = slug or conditionId quoteId = self.safe_string(self.options, 'defaultCollateral', 'USDC') # Polymarket uses USDC currency # Market type - Polymarket is a prediction market platform marketType: MarketType = 'option' # Using 'option' match for prediction markets ammType = self.safe_string(market, 'ammType') # Schema uses 'enableOrderBook'(camelCase) enableOrderBook = self.safe_bool(market, 'enableOrderBook', False) # Market metadata category = self.safe_string(market, 'category') description = self.safe_string(market, 'description') tags = self.safe_value(market, 'tags', []) # Schema uses 'clobTokenIds'(camelCase) - can be string or array clobTokenIds = self.safe_value(market, 'clobTokenIds') if clobTokenIds is None: clobTokenIds = [] if isinstance(clobTokenIds, str): parsed = None try: parsed = json.loads(clobTokenIds) except Exception as e: parsed = None if parsed is not None and parsed != None and len(parsed) is not None: clobTokenIds = [] for i in range(0, len(parsed)): clobTokenIds.append(parsed[i]) else: cleaned = clobTokenIds cleaned = cleaned.replace('[', '').replace(']', '').replace('"', '') clobTokenIdsArray = cleaned.split(',') clobTokenIds = [] for i in range(0, len(clobTokenIdsArray)): v = clobTokenIdsArray[i].strip() if v != '': clobTokenIds.append(v) outcomesInfo = [] length = len(outcomes) if len(outcomePrices) > length: length = len(outcomePrices) if len(clobTokenIds) > length: length = len(clobTokenIds) for i in range(0, length): outcome = None if i < len(outcomes): outcome = outcomes[i] price = None if i < len(outcomePrices): price = self.parse_number(outcomePrices[i]) clobId = None if i < len(clobTokenIds): clobId = clobTokenIds[i] outcomeId = str(i) if clobId is not None: outcomeId = clobId outcomesInfo.append({ 'id': outcomeId, 'name': outcome, 'price': price, 'clobId': clobId, 'assetId': clobId, }) # Parse dates - Schema uses 'endDateIso'(preferred) or 'endDate'(fallback) endDateIso = self.safe_string(market, 'endDateIso') or self.safe_string(market, 'endDate') # Schema uses 'createdAt'(camelCase) createdAt = self.safe_string(market, 'createdAt') createdTimestamp = None if createdAt is not None: createdTimestamp = self.parse8601(createdAt) # Volume and liquidity volume = self.safe_string(market, 'volume') volumeNum = self.safe_number(market, 'volumeNum') liquidity = self.safe_string(market, 'liquidity') liquidityNum = self.safe_number(market, 'liquidityNum') feesEnabled = self.safe_bool(market, 'feesEnabled', False) makerBaseFee = self.safe_number(market, 'makerBaseFee') takerBaseFee = self.safe_number(market, 'takerBaseFee') base = baseId quote = quoteId settle = quote # Use quote # Parse expiry for option symbol formatting # Handle date-only strings(YYYY-MM-DD) by converting to ISO8601 datetime expiry = None expiryDatetime = endDateIso if endDateIso is not None: dateString = endDateIso # Check if it's a date-only string(YYYY-MM-DD format) if dateString.find(':') < 0: # Append time to make it a valid ISO8601 datetime dateString = dateString + 'T00:00:00Z' expiry = self.parse8601(dateString) # Format symbol with expiry date(similar to binance/okx option format) # Format: base/quote:settle-YYMMDD symbol = base + '/' + quote if expiry is not None: ymd = self.yymmdd(expiry) symbol = symbol + ':' + settle + '-' + ymd # Prediction markets don't have strike prices or option types in the schema # These fields are kept strike = None optionType = None contractSize = self.parse_number('1') # Calculate fees based on feesEnabled flag takerFee = self.parse_number('0') makerFee = self.parse_number('0') if feesEnabled: # Fees are enabled - use makerBaseFee and takerBaseFee from schema # These are typically in basis points(e.g., 200 = 2% = 0.02) if takerBaseFee is not None: takerFee = takerBaseFee / 10000 # Convert basis points to decimal if makerBaseFee is not None: makerFee = makerBaseFee / 10000 # Convert basis points to decimal created = self.milliseconds() # TODO change it if createdTimestamp is not None: created = createdTimestamp volumeValue = self.parse_number('0') if volumeNum is not None: volumeValue = volumeNum elif volume is not None: volumeValue = self.parse_number(volume) liquidityValue = self.parse_number('0') if liquidityNum is not None: liquidityValue = liquidityNum elif liquidity is not None: liquidityValue = self.parse_number(liquidity) return { 'id': conditionId, 'symbol': symbol, 'base': base, 'quote': quote, 'settle': settle, 'baseId': baseId, 'quoteId': quoteId, 'settleId': settle, 'type': marketType, 'spot': False, 'margin': False, 'swap': False, 'future': False, 'option': True, # Prediction markets are treated 'active': enableOrderBook and active and not closed and not archived, 'contract': True, 'linear': None, 'inverse': None, 'contractSize': contractSize, 'expiry': expiry, 'expiryDatetime': expiryDatetime, 'strike': strike, 'optionType': optionType, 'taker': takerFee, 'maker': makerFee, 'precision': { 'amount': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts 'price': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts }, 'limits': { 'leverage': { 'min': None, 'max': None, }, 'amount': { 'min': None, 'max': None, }, 'price': { 'min': 0, # Prediction markets are 0-1 'max': 1, # Prediction markets are 0-1 }, 'cost': { 'min': None, 'max': None, }, }, 'created': created, 'info': self.deep_extend(market, { 'outcomes': outcomes, 'outcomePrices': outcomePrices, 'outcomesInfo': outcomesInfo, 'question': question, 'slug': slug, 'category': category, 'description': description, 'tags': tags, 'condition_id': conditionId, 'question_id': questionId, 'asset_id': questionId, 'ammType': ammType, 'enableOrderBook': enableOrderBook, 'volume': volumeValue, 'liquidity': liquidityValue, 'endDateIso': endDateIso, 'createdAt': createdAt, 'createdTimestamp': createdTimestamp, 'clobTokenIds': clobTokenIds, 'quoteDecimals': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts 'baseDecimals': 6, # https://github.com/Polymarket/clob-client/blob/main/src/config.ts }), } def fetch_order_book(self, symbol: str, limit: Int = None, params={}) -> OrderBook: """ fetches the order book for a market https://docs.polymarket.com/api-reference/orderbook/get-order-book-summary :param str symbol: unified symbol of the market to fetch the order book for :param int [limit]: the maximum amount of order book entries to return :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID for the specific outcome(required if market has multiple outcomes) :returns dict: A dictionary of `order book structures ` indexed by market symbols """ self.load_markets() market = self.market(symbol) request: dict = {} # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchOrderBook() requires a token_id parameter for market ' + symbol) request['token_id'] = tokenId response = self.clob_public_get_orderbook_token_id(self.extend(request, params)) return self.parse_order_book(response, symbol) def fetch_order_books(self, symbols: Strings = None, limit: Int = None, params={}) -> OrderBooks: """ fetches information on open orders with bid(buy) and ask(sell) prices, volumes and other data for multiple markets https://docs.polymarket.com/developers/CLOB/clients/methods-public#getorderbooks :param str[]|None symbols: list of unified market symbols, all symbols fetched if None, default is None :param int [limit]: the maximum amount of order book entries to return :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: a dictionary of `order book structures ` indexed by market symbol """ self.load_markets() if symbols is None: symbols = self.symbols # Build list of token IDs to fetch order books for tokenIds: List[str] = [] tokenIdToSymbol: dict = {} for i in range(0, len(symbols)): symbol = symbols[i] market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: return {} # Fetch order books for all token IDs at once using POST /books endpoint # See https://docs.polymarket.com/api-reference/orderbook/get-multiple-order-books-summaries-by-request # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: array of order book objects, each with asset_id matching token_id requestBody = [] for i in range(0, len(tokenIds)): requestItem: dict = {'token_id': tokenIds[i]} if limit is not None: requestItem['limit'] = limit requestBody.append(requestItem) response = self.clob_public_post_books(self.extend({'requests': requestBody}, params)) # Parse response: array of order book objects, each with asset_id field # Response is directly an array: [{asset_id: "...", bids: [...], asks: [...]}, ...] result: dict = {} if isinstance(response, list): for i in range(0, len(response)): orderbookData = response[i] assetId = self.safe_string(orderbookData, 'asset_id') symbol = tokenIdToSymbol[assetId] if symbol is not None: try: orderbook = self.parse_order_book(orderbookData, symbol) result[symbol] = orderbook except Exception as e: # Skip markets that fail to parse continue return result def parse_order_book(self, orderbook: dict, symbol: Str = None, timestamp: Int = None, bidsKey: Str = 'bids', asksKey: Str = 'asks', priceKey: Int = 0, amountKey: Int = 1, countOrIdKey: Int = 2) -> OrderBook: # Polymarket CLOB orderbook format(from /book endpoint) # See https://docs.polymarket.com/developers/CLOB/clients/methods-public#getorderbook # { # "market": "string", # "asset_id": "string", # "timestamp": "string", # "bids": [ # { # "price": "0.65", # string # "size": "100" # string # } # ], # "asks": [ # { # "price": "0.66", # string # "size": "50" # string # } # ], # "min_order_size": "string", # "tick_size": "string", # "neg_risk": boolean, # "hash": "string" # } # Note: Ensure bids and asks are always arrays to avoid Python transpilation issues # safeList can return None, which becomes None in Python, causing len() to fail bids = self.safe_list(orderbook, 'bids', []) or [] asks = self.safe_list(orderbook, 'asks', []) or [] # Note: Using 'const' without explicit type annotation to avoid Python transpilation issues # The transpiler incorrectly preserves TypeScript tuple type annotations(e.g., ': [number, number][]') in Python code parsedBids = [] parsedAsks = [] for i in range(0, len(bids)): bid = bids[i] price = self.safe_number(bid, 'priceNumber', self.safe_number(bid, 'price')) amount = self.safe_number(bid, 'sizeNumber', self.safe_number(bid, 'size')) if price is not None and amount is not None: parsedBids.append([price, amount]) for i in range(0, len(asks)): ask = asks[i] price = self.safe_number(ask, 'priceNumber', self.safe_number(ask, 'price')) amount = self.safe_number(ask, 'sizeNumber', self.safe_number(ask, 'size')) if price is not None and amount is not None: parsedAsks.append([price, amount]) # Extract timestamp from orderbook response if available orderbookTimestamp = self.safe_string(orderbook, 'timestamp') finalTimestamp = timestamp if orderbookTimestamp is not None: # CLOB API returns timestamp string, convert to milliseconds finalTimestamp = self.parse8601(orderbookTimestamp) # Extract tick_size and neg_risk from orderbook if available(useful metadata) # These are also available via get_tick_size() and get_neg_risk() endpoints # Based on py-clob-client: get_tick_size() and get_neg_risk() tickSize = self.safe_string(orderbook, 'tick_size') negRisk = self.safe_bool(orderbook, 'neg_risk') minOrderSize = self.safe_string(orderbook, 'min_order_size') result: OrderBook = { 'symbol': symbol, 'bids': parsedBids, 'asks': parsedAsks, 'timestamp': finalTimestamp, 'datetime': self.iso8601(finalTimestamp), 'nonce': None, } # Include tick_size, neg_risk, and min_order_size in info if available(useful metadata) if tickSize is not None or negRisk is not None or minOrderSize is not None: metadata: dict = {} if tickSize is not None: metadata['tick_size'] = tickSize if negRisk is not None: metadata['neg_risk'] = negRisk if minOrderSize is not None: metadata['min_order_size'] = minOrderSize result['info'] = self.extend(orderbook, metadata) return result def fetch_ticker(self, symbol: str, params={}) -> Ticker: """ fetches a price ticker, a statistical calculation with the information calculated over the past 24 hours for a specific market https://docs.polymarket.com/api-reference/pricing/get-market-price https://docs.polymarket.com/api-reference/pricing/get-midpoint-price :param str symbol: unified symbol of the market to fetch the ticker for :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID for the specific outcome(required if market has multiple outcomes) :param str [params.side]: the side: 'BUY' or 'SELL'(default: 'BUY') :returns dict: a `ticker structure ` **Currently Populated Fields:** - `bid` - Best bid price from POST /prices endpoint(BUY side) - `ask` - Best ask price from POST /prices endpoint(SELL side) - `last` - Midpoint price from GET /midpoint or lastTradePrice from market info - `open` - Calculated approximation: last / (1 + oneDayPriceChange) - `change` - Calculated: last - open - `percentage` - From oneDayPriceChange * 100(from market info) - `volume` - From volumeNum or volume(from market info) - `timestamp` - From updatedAt(parsed from ISO string) - `datetime` - ISO8601 formatted timestamp **Currently Undefined Fields(Available via Additional API Calls):** - `high` - Can be fetched from GET /prices-history(24h price history) or GET /trades(24h trades) - `low` - Can be fetched from GET /prices-history(24h price history) or GET /trades(24h trades) - `bidVolume` - Can be calculated from GET /book(order book) by summing all bid sizes - `askVolume` - Can be calculated from GET /book(order book) by summing all ask sizes - `vwap` - Can be calculated from GET /trades(24h trades) using volume-weighted average - `average` - Not available - `indexPrice` - Not available - `markPrice` - Not available **Enhancement Options:** 1. **For High/Low/More Accurate Open:** - Use fetchOHLCV() to get 24h price history: `await exchange.fetchOHLCV(symbol, '1h', since24hAgo, None, {token_id: tokenId})` - Calculate high/low from OHLCV data - Use first candle's open price for accurate 24h open - API: GET /prices-history(see https://docs.polymarket.com/developers/CLOB/timeseries) 2. **For VWAP:** - Use fetchTrades() to get 24h trades: `await exchange.fetchTrades(symbol, since24hAgo, None, {token_id: tokenId})` - Calculate: vwap = sum(trade.cost) / sum(trade.amount) - API: GET /trades(see https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets) 3. **For Bid/Ask Volumes:** - Use fetchOrderBook() to get order book: `await exchange.fetchOrderBook(symbol, None, {token_id: tokenId})` - Calculate: bidVolume = sum of all bid[1](sizes), askVolume = sum of all ask[1](sizes) - API: GET /book(see https://docs.polymarket.com/api-reference/orderbook/get-order-book-summary) 4. **For More Accurate Last Price:** - Use GET /last-trade-price endpoint: `await exchange.clobPublicGetLastTradePrice({token_id: tokenId})` - API: GET /last-trade-price(see https://docs.polymarket.com/api-reference/trades/get-last-trade-price) """ self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchTicker() requires a token_id parameter for market ' + symbol) # Fetch prices using POST /prices endpoint with both BUY and SELL sides # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request pricesResponse = self.clob_public_post_prices(self.extend({ 'requests': [ {'token_id': tokenId, 'side': 'BUY'}, {'token_id': tokenId, 'side': 'SELL'}, ], }, params)) # Parse prices response: {[token_id]: {BUY: "price", SELL: "price"}, ...} tokenPrices = self.safe_dict(pricesResponse, tokenId, {}) buyPrice = self.safe_string(tokenPrices, 'BUY') sellPrice = self.safe_string(tokenPrices, 'SELL') # Fetch midpoint if available(optional, ignore if not provided) midpoint = None try: midpointResponse = self.clob_public_get_midpoint(self.extend({'token_id': tokenId}, params)) midpoint = self.safe_string(midpointResponse, 'mid') except Exception as e: # Ignore midpoint if not available or fails midpoint = None # Combine pricing data with market info - already loaded from fetchMarkets combinedData = self.deep_extend(marketInfo, { 'buyPrice': buyPrice, 'sellPrice': sellPrice, 'midpoint': midpoint, }) return self.parse_ticker(combinedData, market) def fetch_tickers(self, symbols: Strings = None, params={}) -> Tickers: """ fetches price tickers for multiple markets, statistical information calculated over the past 24 hours for each market https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices :param str[]|None symbols: unified symbols of the markets to fetch the ticker for, all market tickers are returned if not assigned :param dict [params]: extra parameters specific to the exchange API endpoint :param boolean [params.fetchSpreads]: if True, also fetch bid-ask spreads for all markets(default: False) :returns dict: a dictionary of `ticker structures ` """ self.load_markets() # Build list of token IDs to fetch prices for tokenIds: List[str] = [] tokenIdToSymbol: dict = {} symbolsToFetch = symbols or self.symbols for i in range(0, len(symbolsToFetch)): symbol = symbolsToFetch[i] market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] tokenIds.append(tokenId) tokenIdToSymbol[tokenId] = symbol if len(tokenIds) == 0: return {} # Build requests array for POST /prices endpoint # Each token needs both BUY and SELL sides # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request requests = [] for i in range(0, len(tokenIds)): tokenId = tokenIds[i] requests.append({'token_id': tokenId, 'side': 'BUY'}) requests.append({'token_id': tokenId, 'side': 'SELL'}) # Fetch prices for all token IDs at once using POST /prices endpoint # Response format: {[token_id]: {BUY: "price", SELL: "price"}, ...} # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request pricesResponse = self.clob_public_post_prices(self.extend({'requests': requests}, params)) # Optionally fetch spreads for all token IDs # See https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spreads fetchSpreads = self.safe_bool(params, 'fetchSpreads', False) spreadsResponse = {} if fetchSpreads: try: spreadsResponse = self.clob_public_post_spreads(self.extend({'token_ids': tokenIds}, params)) except Exception as e: spreadsResponse = {} # Build market data map for efficient lookup tokenIdToMarket = {} for i in range(0, len(tokenIds)): tokenId = tokenIds[i] symbol = tokenIdToSymbol[tokenId] tokenIdToMarket[tokenId] = self.market(symbol) # Parse prices and build tickers(no additional fetching during parsing) tickers: dict = {} for i in range(0, len(tokenIds)): tokenId = tokenIds[i] symbol = tokenIdToSymbol[tokenId] market = tokenIdToMarket[tokenId] try: # Get prices from the response(both BUY and SELL are in the same response) tokenPrices = self.safe_dict(pricesResponse, tokenId, {}) buyPrice = self.safe_string(tokenPrices, 'BUY') sellPrice = self.safe_string(tokenPrices, 'SELL') # Get spread if available spread = self.safe_string(spreadsResponse, tokenId) # Use market info data(already loaded from fetchMarkets) marketInfo = self.safe_dict(market, 'info', {}) # Combine pricing data with market info combinedData = self.deep_extend(marketInfo, { 'buyPrice': buyPrice, 'sellPrice': sellPrice, 'spread': spread, }) ticker = self.parse_ticker(combinedData, market) tickers[symbol] = ticker except Exception as e: # Skip markets that fail to parse continue return tickers def parse_ticker(self, ticker: dict, market: Market = None) -> Ticker: """ parses a ticker data structure from Polymarket API response :param dict ticker: ticker data structure from Polymarket API :param dict [market]: market structure :returns dict: a `ticker structure ` **Data Sources:** - Market info from fetchMarkets()(volume, oneDayPriceChange, lastTradePrice, etc.) - Pricing API(buyPrice, sellPrice, midpoint) - Market metadata(updatedAt, volume24hr, volume1wk, volume1mo, volume1yr) **Currently Parsed Fields:** - `bid` - From buyPrice(POST /prices BUY side) or bestBid(market info) - `ask` - From sellPrice(POST /prices SELL side) or bestAsk(market info) - `last` - From midpoint(GET /midpoint) or lastTradePrice(market info) - `open` - Calculated: last / (1 + oneDayPriceChange) when both available - `change` - Calculated: last - open - `percentage` - From oneDayPriceChange * 100 - `volume` - From volumeNum or volume(market info) - `timestamp` - From updatedAt(ISO string parsed to milliseconds) - `datetime` - ISO8601 formatted timestamp **Fields Set to Undefined(Can Be Enhanced):** - `high` - Not available in current data sources. Can be calculated from: - Price history: Math.max(...ohlcvData.map(c => c[2])) where c[2] is high - Trades: Math.max(...trades.map(t => t.price)) - `low` - Not available in current data sources. Can be calculated from: - Price history: Math.min(...ohlcvData.map(c => c[3])) where c[3] is low - Trades: Math.min(...trades.map(t => t.price)) - `bidVolume` - Not available. Can be calculated from order book: - orderbook.bids.reduce((sum, bid) => sum + bid[1], 0) - `askVolume` - Not available. Can be calculated from order book: - orderbook.asks.reduce((sum, ask) => sum + ask[1], 0) - `vwap` - Not available. Can be calculated from trades: - totalCost = trades.reduce((sum, t) => sum + t.cost, 0) - totalVolume = trades.reduce((sum, t) => sum + t.amount, 0) - vwap = totalCost / totalVolume **To Enhance Ticker Data:** Before calling parseTicker(), you can fetch additional data and add it to the ticker dict: ```typescript # Example: Add high/low from price history since24h = exchange.milliseconds() - 24 * 60 * 60 * 1000 ohlcv = exchange.fetchOHLCV(symbol, '1h', since24h, None, {token_id: tokenId}) if len(ohlcv) > 0: highs = ohlcv.map(c => c[2]) # OHLCV[2] is high lows = ohlcv.map(c => c[3]) # OHLCV[3] is low ticker['high'] = Math.max(...highs) ticker['low'] = Math.min(...lows) ticker['open'] = ohlcv[0][1] # First candle's open } # Example: Add VWAP from trades trades = exchange.fetchTrades(symbol, since24h, None, {token_id: tokenId}) if len(trades) > 0: totalCost = 0 totalVolume = 0 for i in range(0, len(trades)): totalCost += trades[i]['cost'] totalVolume += trades[i]['amount'] } ticker['vwap'] = totalVolume > totalCost / totalVolume if 0 else None } # Example: Add bid/ask volumes from order book orderbook = exchange.fetchOrderBook(symbol, None, {token_id: tokenId}) bidVolume = 0 askVolume = 0 for i in range(0, len(orderbook['bids'])): bidVolume += orderbook['bids'][i][1] } for i in range(0, len(orderbook['asks'])): askVolume += orderbook['asks'][i][1] } ticker['bidVolume'] = bidVolume ticker['askVolume'] = askVolume ``` """ # Polymarket ticker format from market data symbol = market['symbol'] if market else None # Parse outcome prices outcomePricesStr = self.safe_string(ticker, 'outcomePrices') outcomePrices = [] if outcomePricesStr: try: parsed = json.loads(outcomePricesStr) # Note: Ensure all elements are numbers - json.loadsmay return strings # Convert each element to a number to avoid Python multiplication errors if parsed is not None and parsed != None and len(parsed) is not None: for i in range(0, len(parsed)): price = self.parse_number(parsed[i]) if price is not None: outcomePrices.append(price) except Exception as e: # Note: Using for loop instead of .map() to avoid Python transpilation issues # Arrow functions with type annotations(e.g., '(p: string) =>') are incorrectly preserved in Python pricesArray = outcomePricesStr.split(',') for i in range(0, len(pricesArray)): price = self.parse_number(pricesArray[i].strip()) if price is not None: outcomePrices.append(price) last = None bid = None ask = None high = None low = None # Volume data volume = self.safe_number(ticker, 'volumeNum', self.safe_number(ticker, 'volume')) volume24hr = self.safe_number(ticker, 'volume24hr') volume1wk = self.safe_number(ticker, 'volume1wk') volume1mo = self.safe_number(ticker, 'volume1mo') volume1yr = self.safe_number(ticker, 'volume1yr') # Price changes oneDayPriceChange = self.safe_number(ticker, 'oneDayPriceChange') # Best bid/ask from pricing API(BUY = bid, SELL = ask) buyPrice = self.safe_number(ticker, 'buyPrice') sellPrice = self.safe_number(ticker, 'sellPrice') midpoint = self.safe_number(ticker, 'midpoint') # Use pricing API data if available if buyPrice is not None: bid = buyPrice if sellPrice is not None: ask = sellPrice if midpoint is not None: last = midpoint # Fallback to ticker data if pricing API data not available bestBid = self.safe_number(ticker, 'bestBid') bestAsk = self.safe_number(ticker, 'bestAsk') lastTradePrice = self.safe_number(ticker, 'lastTradePrice') if bid is None and bestBid is not None: bid = bestBid if ask is None and bestAsk is not None: ask = bestAsk if last is None and lastTradePrice is not None: last = lastTradePrice # Timestamp updatedAtString = self.safe_string(ticker, 'updatedAt') timestamp = self.parse8601(updatedAtString) if updatedAtString else None datetime = self.iso8601(timestamp) if timestamp else None # Open(previous closing price - approximated) open = last is not None and oneDayPriceChange is not last / (1 + oneDayPriceChange) if None else None # Change and percentage change = last is not None and open is not last - open if None else None percentage = oneDayPriceChange is not oneDayPriceChange * 100 if None else None # Add additional Polymarket-specific fields to info tickerInfo = self.safe_dict(ticker, 'info', {}) extendedInfo = self.deep_extend(tickerInfo, { 'buyPrice': buyPrice, 'sellPrice': sellPrice, 'midpoint': midpoint, 'lastTradePrice': lastTradePrice, 'volume24hr': volume24hr, 'volume1wk': volume1wk, 'volume1mo': volume1mo, 'volume1yr': volume1yr, }) return { 'symbol': symbol, 'info': self.deep_extend(ticker, {'info': extendedInfo}), 'timestamp': timestamp, 'datetime': datetime, 'high': high, 'low': low, 'bid': bid, 'bidVolume': None, 'ask': ask, 'askVolume': None, 'vwap': None, 'open': open, 'close': last, 'last': last, 'previousClose': open, 'change': change, 'percentage': percentage, 'average': None, 'baseVolume': volume, 'quoteVolume': volume, 'indexPrice': None, 'markPrice': None, } def fetch_trades(self, symbol: str, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ get the list of most recent trades for a particular symbol https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets :param str symbol: unified symbol of the market to fetch trades for :param int [since]: timestamp in ms of the earliest trade to fetch :param int [limit]: the maximum amount of trades to fetch(default: 100, max: 10000) :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.offset]: offset for pagination(default: 0, max: 10000) :param boolean [params.takerOnly]: if True, returns only trades where the user is the taker(default: True) :param str [params.side]: filter by side: 'BUY' or 'SELL' :returns Trade[]: a list of `trade structures ` """ self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get condition_id from market info(self is the market ID for Polymarket) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) request: dict = { 'market': [conditionId], # Data API expects an array of condition IDs } # Note: Data API /trades endpoint supports limit(default: 100, max: 10000) and offset for pagination # The 'since' parameter is not directly supported by the REST API if limit is not None: request['limit'] = min(limit, 10000) # Cap at max 10000 offset = self.safe_integer(params, 'offset') if offset is not None: request['offset'] = offset takerOnly = self.safe_bool(params, 'takerOnly', True) request['takerOnly'] = takerOnly side = self.safe_string_upper(params, 'side') if side is not None: request['side'] = side response = self.data_public_get_trades(self.extend(request, self.omit(params, ['offset', 'takerOnly', 'side']))) tradesData = [] if isinstance(response, list): tradesData = response else: dataList = self.safe_list(response, 'data', []) if dataList is not None: tradesData = dataList return self.parse_trades(tradesData, market, since, limit) def parse_trade(self, trade: dict, market: Market = None) -> Trade: # Detect Data API format(has conditionId field) vs CLOB format(has market/asset_id fields) # Check for both camelCase and snake_case for robustness conditionId = self.safe_string_2(trade, 'conditionId', 'condition_id') isDataApiFormat = conditionId is not None if isDataApiFormat: # Data API format: https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets # { # "proxyWallet": "0x...", # "side": "BUY", # "asset": "", # "conditionId": "0x...", # "size": 123, # "price": 123, # "timestamp": 123, # "transactionHash": "0x...", # ... # } # Use transactionHash, check both camelCase and snake_case id = self.safe_string_2(trade, 'transactionHash', 'transaction_hash') symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif conditionId is not None: resolved = self.safe_market(conditionId, None) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = conditionId timestampSeconds = self.safe_integer(trade, 'timestamp') timestamp = None if timestampSeconds is not None: timestamp = timestampSeconds * 1000 side = self.safe_string_lower(trade, 'side') price = self.safe_number(trade, 'price') amount = self.safe_number(trade, 'size') cost = None if price is not None and amount is not None: cost = price * amount # Data API doesn't provide fee information return { 'id': id, 'info': trade, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp), 'symbol': symbol, 'type': None, 'side': side, 'takerOrMaker': None, # Data API doesn't provide self information 'price': price, 'amount': amount, 'cost': cost, 'fee': None, # Data API doesn't provide fee information 'order': None, # Data API doesn't provide order ID } else: # CLOB Trade format(backward compatibility) # interface Trade { # id: string # taker_order_id: string # market: string # asset_id: string # side: Side # size: string # fee_rate_bps: string # price: string # status: string # match_time: string # last_update: string # outcome: string # bucket_index: number # owner: string # maker_address: string # maker_orders: MakerOrder[] # transaction_hash: string # trader_side: "TAKER" | "MAKER" # } id = self.safe_string(trade, 'id') assetId = self.safe_string(trade, 'asset_id') tradeMarket = self.safe_string(trade, 'market') symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif tradeMarket is not None: resolved = self.safe_market(tradeMarket, None) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = tradeMarket elif assetId is not None: resolved = self.safe_market(assetId, market) resolvedSymbol = self.safe_string(resolved, 'symbol') if resolvedSymbol is not None: symbol = resolvedSymbol else: symbol = assetId matchTime = self.safe_integer(trade, 'match_time') timestamp = None if matchTime is not None: timestamp = matchTime * 1000 # Top-level fields are from the taker perspective; for maker trades use maker_orders side = self.safe_string_lower(trade, 'side') price = self.safe_number(trade, 'price') amount = self.safe_number(trade, 'size') feeRateBps = self.safe_number(trade, 'fee_rate_bps') traderSide = self.safe_string_upper(trade, 'trader_side') if traderSide == 'MAKER': makerOrders = self.safe_value(trade, 'maker_orders', []) proxyWallet = self.get_proxy_wallet_address() userAddress = proxyWallet.lower() matched = False for i in range(0, len(makerOrders)): m = makerOrders[i] mAddr = self.safe_string(m, 'maker_address') if mAddr is not None: mAddrLower = mAddr.lower() if mAddrLower == userAddress: price = self.safe_number(m, 'price') amount = self.safe_number(m, 'matched_amount') side = self.safe_string_lower(m, 'side') feeRateBps = self.safe_number(m, 'fee_rate_bps') matched = True break if not matched: m = makerOrders[0] price = self.safe_number(m, 'price') amount = self.safe_number(m, 'matched_amount') side = self.safe_string_lower(m, 'side') feeRateBps = self.safe_number(m, 'fee_rate_bps') feeCost = None if feeRateBps is not None and price is not None and amount is not None: feeCost = price * amount * feeRateBps / 10000 fee = None if feeCost is not None: fee = { 'cost': feeCost, 'currency': self.safe_string(self.options, 'defaultCollateral', 'USDC'), 'rate': feeRateBps is not feeRateBps / 10000 if None else None, } cost = price * amount if (price is not None and amount is not None) else None return { 'id': id, 'info': trade, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp), 'symbol': symbol, 'type': None, 'side': side, 'takerOrMaker': self.safe_string_lower(trade, 'trader_side'), 'price': price, 'amount': amount, 'cost': cost, 'fee': fee, 'order': self.safe_string(trade, 'taker_order_id'), } def fetch_ohlcv(self, symbol: str, timeframe: str = '1h', since: Int = None, limit: Int = None, params={}) -> List[list]: """ fetches historical candlestick data containing the open, high, low, and close price, and the volume of a market https://docs.polymarket.com/developers/CLOB/clients/methods-public#getpriceshistory :param str symbol: unified symbol of the market to fetch OHLCV data for :param str timeframe: the length of time each candle represents :param int [since]: timestamp in ms of the earliest candle to fetch :param int [limit]: the maximum amount of candles to fetch :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID for the specific outcome(required if market has multiple outcomes) :param int [params.endTs]: timestamp in seconds for the ending date filter :param number [params.fidelity]: data fidelity/quality :returns int[][]: A list of candles ordered, open, high, low, close, volume """ self.load_markets() market = self.market(symbol) request: dict = {} # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: marketInfo = self.safe_dict(market, 'info', {}) clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchOHLCV() requires a token_id parameter for market ' + symbol) request['market'] = tokenId # API uses 'market' parameter for token_id # Note: REST API /prices-history endpoint requires either: # 1. startTs and endTs(mutually exclusive with interval) # 2. interval(mutually exclusive with startTs/endTs) # See https://docs.polymarket.com/developers/CLOB/timeseries # Supported intervals: "1m", "1h", "6h", "1d", "1w", "max" # CCXT will automatically reject unsupported timeframes based on the 'timeframes' definition endTs = self.safe_integer(params, 'endTs') if since is not None or endTs is not None: # Use startTs/endTs when time range is specified if since is not None: # Convert milliseconds to seconds for API request['startTs'] = self.parse_to_int(since / 1000) if endTs is not None: request['endTs'] = endTs else: # Use interval when no time range is specified # CCXT will validate the timeframe against the 'timeframes' definition # Map to API format(timeframe should already be validated by CCXT) request['interval'] = timeframe # Fidelity parameter controls data granularity(e.g., 720 for 12-hour intervals) # If not provided, API may use default fidelity fidelity = self.safe_number(params, 'fidelity') # Polymarket enforces minimum fidelity per interval(e.g. interval=1m requires fidelity>=10) # Avoid leaking a server-side validation error back to the user when a too-low value is supplied. if timeframe == '1m': if fidelity is None: fidelity = 10 else: fidelity = min(10, fidelity) if fidelity is not None: request['fidelity'] = fidelity remainingParams = self.omit(params, ['token_id', 'endTs', 'fidelity']) response = self.clob_public_get_prices_history(self.extend(request, remainingParams)) ohlcvData = [] if isinstance(response, list): ohlcvData = response else: # Response has 'history' key containing the array ohlcvData = self.safe_list(response, 'history', []) or [] return self.parse_ohlcvs(ohlcvData, market, timeframe, since, limit) def parse_ohlcv(self, ohlcv: Any, market: Market = None) -> list: # Polymarket MarketPrice format from getPricesHistory # See https://docs.polymarket.com/developers/CLOB/clients/methods-public#getpriceshistory # { # "t": number, # timestamp in seconds # "p": number # price # } # Note: Polymarket only returns price data, not full OHLCV # We'll use the price, high, low, and close, with volume timestamp = self.safe_integer(ohlcv, 't') price = self.safe_number(ohlcv, 'p') # Convert timestamp from seconds to milliseconds if timestamp is not None: timestamp = timestamp * 1000 return [ timestamp, price, # open price, # high(same since we only have price) price, # low(same since we only have price) price, # close 0, # volume(not available in price history) ] def get_rounding_config(self, tickSize: str) -> dict: """ Get rounding configuration based on tick size(matches ROUNDING_CONFIG from official SDK) :param str tickSize: tick size string(e.g., '0.1', '0.01', '0.001', '0.0001') :returns dict: rounding configuration with price, size, and amount decimal places """ # Determine rounding config based on tick size(matches ROUNDING_CONFIG from SDK) # Returns: {price: number, size: number, amount: number} priceDecimals = 2 sizeDecimals = 2 amountDecimals = 4 if tickSize == '0.1': priceDecimals = 1 sizeDecimals = 2 amountDecimals = 3 elif tickSize == '0.01': priceDecimals = 2 sizeDecimals = 2 amountDecimals = 4 elif tickSize == '0.001': priceDecimals = 3 sizeDecimals = 2 amountDecimals = 5 elif tickSize == '0.0001': priceDecimals = 4 sizeDecimals = 2 amountDecimals = 6 return { 'price': priceDecimals, 'size': sizeDecimals, 'amount': amountDecimals, } def round_down(self, value: str, decimals: float) -> str: """ Round down(truncate) a value to specific decimal places :param str value: value to round down :param number decimals: number of decimal places :returns str: rounded down value """ return self.decimal_to_precision(value, 0, decimals, 2, 5) def round_normal(self, value: str, decimals: float) -> str: """ Round a value normally to specific decimal places :param str value: value to round :param number decimals: number of decimal places :returns str: rounded value """ return self.decimal_to_precision(value, 1, decimals, 2, 5) def round_up(self, value: str, decimals: float) -> str: """ Round up a value to specific decimal places :param str value: value to round up :param number decimals: number of decimal places :returns str: rounded up value """ return self.decimal_to_precision(value, 2, decimals, 2, 5) def decimal_places(self, value: str) -> float: """ Count the number of decimal places in a string value :param str value: value to count decimal places for :returns number: number of decimal places """ parts = value.split('.') if len(parts) == 2: return len(parts[1]) return 0 def to_token_decimals(self, value: str, decimals: float) -> str: """ Convert a value to token decimals(smallest unit) by multiplying by 10^decimals and truncating :param str value: value to convert :param number decimals: number of decimals(e.g., 6 for USDC, 18 for tokens) :returns str: value in smallest unit """ # Multiply by 10^decimals and truncate to integer multiplier = self.integer_precision_to_amount(self.number_to_string(-decimals)) return Precise.string_div(Precise.string_mul(value, multiplier), '1', 0) def build_and_sign_order(self, tokenId: str, side: str, size: str, price: str = None, market: Market = None, params={}) -> dict: """ Builds and signs an order with EIP-712 according to Polymarket order-utils specification https://github.com/Polymarket/clob-order-utils https://github.com/Polymarket/clob-client/blob/main/src/order-builder/builder.ts https://github.com/Polymarket/python-order-utils/blob/main/py_order_utils/builders/order_builder.py :param str tokenId: the token ID :param str side: 'BUY' or 'SELL' :param str size: order size :param str [price]: order price(required for limit orders) :param dict [market]: market structure(optional, used to get fees) :param dict [params]: extra parameters :param number [params.expiration]: expiration timestamp in seconds(default: 30 days from now) :param number [params.nonce]: order nonce(default: 0) :param number [params.feeRateBps]: fee rate in basis points(default: from market or 200 bps) :param str [params.maker]: maker address(default: getMainWalletAddress()) :param str [params.taker]: taker address(default: zero address) :param str [params.signer]: signer address(default: maker address) :param number [params.signatureType]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :param str [params.orderType]: order type: 'GTC', 'IOC', 'FOK', 'GTD'(default: 'GTC' for limit orders, 'FOK' for market orders) :returns dict: signed order object ready for submission """ # Get zero address constant(matches py-clob-client ZERO_ADDRESS) # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/constants.py zeroAddress = self.safe_string(self.options, 'zeroAddress', '0x0000000000000000000000000000000000000000') # Get signature type signatureType = self.get_signature_type(params) # Get maker address(wallet address) - checksummed for signing maker = self.safe_string(params, 'maker') if maker is None: signatureTypes = self.safe_dict(self.options, 'signatureTypes', {}) eoaSignatureType = self.safe_integer(signatureTypes, 'EOA') if signatureType == eoaSignatureType: maker = self.get_main_wallet_address() else: maker = self.get_proxy_wallet_address() normalizedMaker = self.normalize_address(maker) # Get taker address(default: zero address for open orders) taker = self.safe_string(params, 'taker', zeroAddress) normalizedTaker = self.normalize_address(taker) # Get fee rate in basis points from market or params feeRateBps = self.safe_integer(params, 'feeRateBps') if feeRateBps is None: if market is not None: # Try to get fee from market structure marketInfo = self.safe_dict(market, 'info', {}) # First try takerBaseFee from market info(in basis points) feeRateBps = self.safe_integer(marketInfo, 'takerBaseFee') if feeRateBps is None: # Try taker fee from parsed market(decimal, convert to basis points) takerFee = self.safe_number(market, 'taker') if takerFee is not None: feeRateBps = int(round(takerFee * 10000)) # Fallback to default fee rate from options if not found in market if feeRateBps is None: feeRateBps = self.safe_integer(self.options, 'defaultFeeRateBps', 200) # Get expiration(default: from options.defaultExpirationDays, or 30 days from now in seconds) expiration = self.safe_integer(params, 'expiration') if expiration is None: nowSeconds = int(math.floor(self.milliseconds()) / 1000) defaultExpirationDays = self.safe_integer(self.options, 'defaultExpirationDays', 30) expiration = nowSeconds + (defaultExpirationDays * 24 * 60 * 60) # Get nonce(default: current timestamp in seconds) nonce = self.safe_integer(params, 'nonce') if nonce is None: nonce = 0 # Default nonce is 0 # Get signer address(default: maker address) signer = self.safe_string(params, 'signer') if signer is None: signer = self.get_main_wallet_address() normalizedSigner = self.normalize_address(signer) # Generate salt(unique integer based on microseconds) # Using microseconds for better uniqueness without relying on Math.random() salt = int(math.floor(self.milliseconds()) / 1000) # Calculate makerAmount and takerAmount from size and price # Key steps: 1) Round down size first, 2) Calculate other amount, 3) Round if needed, 4) Convert to smallest units # Get precision from market info or use defaults(USDC: 6 decimals, Tokens: 18 decimals) orderMarketInfo: Any = {} if market is not None: orderMarketInfo = self.safe_dict(market, 'info', {}) marketPrecision: Any = {} if market is not None: marketPrecision = self.safe_dict(market, 'precision', {}) quoteDecimals = self.safe_integer(orderMarketInfo, 'quoteDecimals', self.safe_integer(marketPrecision, 'price')) baseDecimals = self.safe_integer(orderMarketInfo, 'baseDecimals', self.safe_integer(marketPrecision, 'amount')) defaultTickSize = self.safe_string(self.options, 'defaultTickSize') tickSize = self.safe_string(orderMarketInfo, 'tick_size', defaultTickSize) roundingConfig = self.get_rounding_config(tickSize) priceDecimals = self.safe_integer(roundingConfig, 'price', 2) sizeDecimals = self.safe_integer(roundingConfig, 'size', 2) amountDecimals = self.safe_integer(roundingConfig, 'amount', 4) makerAmount: str takerAmount: str isBuy = (side.upper() == 'BUY') # Get price: from parameter, or from params.marketPrice for market orders orderPrice = price if orderPrice is None: orderPrice = self.safe_string(params, 'marketPrice') if orderPrice is None: raise ArgumentsRequired(self.id + ' buildAndSignOrder() requires a price parameter or params.marketPrice') # Round price and size first, then calculate amounts(same logic for limit and market orders) rawPrice = self.round_normal(orderPrice, priceDecimals) # Check if self is a market order for special decimal handling orderType = self.safe_string(params, 'orderType', 'limit') isMarketOrder = (orderType == 'market') # Get rounding buffer constant roundingBuffer = self.safe_integer(self.options, 'roundingBufferDecimals', 4) # Determine decimal precision based on order type and side makerDecimals = 0 takerDecimals = 0 if isMarketOrder: # Get market order decimal limits for quote(USDC) and base(tokens) marketOrderQuoteDecimals = self.safe_integer(self.options, 'marketOrderQuoteDecimals', 2) marketOrderBaseDecimals = self.safe_integer(self.options, 'marketOrderBaseDecimals', 4) if isBuy: # Market buy orders: maker gives USDC(quote), taker gives tokens(base) makerDecimals = marketOrderQuoteDecimals takerDecimals = marketOrderBaseDecimals else: # Market sell orders: maker gives tokens(base), taker gives USDC(quote) makerDecimals = marketOrderBaseDecimals takerDecimals = marketOrderQuoteDecimals else: # Limit orders: use amountDecimals for both makerDecimals = amountDecimals takerDecimals = amountDecimals if isBuy: # BUY: maker gives USDC, wants tokens # Round down size first rawTakerAmt = self.round_down(size, sizeDecimals) # Round taker amount to max decimals if self.decimal_places(rawTakerAmt) > takerDecimals: rawTakerAmt = self.round_down(rawTakerAmt, takerDecimals) # Calculate maker amount: raw_maker_amt = raw_taker_amt * raw_price # Do NOT round calculated amounts - preserve full precision for accurate calculations # The decimal limits apply to input size and final representation, not intermediate calculations rawMakerAmt = Precise.string_mul(rawTakerAmt, rawPrice) # Convert to smallest units: maker gives USDC(quoteDecimals), taker gives tokens(baseDecimals) makerAmount = self.to_token_decimals(rawMakerAmt, quoteDecimals) takerAmount = self.to_token_decimals(rawTakerAmt, baseDecimals) else: # SELL: maker gives tokens, wants USDC # Round down size first rawMakerAmt = self.round_down(size, sizeDecimals) # Round maker amount to max decimals if self.decimal_places(rawMakerAmt) > makerDecimals: rawMakerAmt = self.round_down(rawMakerAmt, makerDecimals) # Calculate taker amount: raw_taker_amt = raw_maker_amt * raw_price # Do NOT round calculated amounts - preserve full precision for accurate calculations # The decimal limits apply to input size and final representation, not intermediate calculations rawTakerAmt = Precise.string_mul(rawMakerAmt, rawPrice) # Convert to smallest units: maker gives tokens(baseDecimals), taker gives USDC(quoteDecimals) makerAmount = self.to_token_decimals(rawMakerAmt, baseDecimals) takerAmount = self.to_token_decimals(rawTakerAmt, quoteDecimals) sideInt = self.get_side(side, params) order: dict = { 'salt': str(salt), # uint256 'maker': normalizedMaker, # address 'signer': normalizedSigner, # address 'taker': normalizedTaker, # address 'tokenId': str(tokenId), # uint256 'makerAmount': str(makerAmount), # uint256 'takerAmount': str(takerAmount), # uint256 'expiration': str(expiration), # uint256 'nonce': str(nonce), # uint256 'feeRateBps': str(feeRateBps), # uint256 'side': sideInt, # uint8: number(0 or 1) 'signatureType': signatureType, # uint8: number(0, 1, or 2) } chainId = self.safe_integer(self.options, 'chainId') orderDomainName = self.safe_string(self.options, 'orderDomainName') orderDomainVersion = self.safe_string(self.options, 'orderDomainVersion') contractConfig = self.get_contract_config(chainId) verifyingContract = self.normalize_address(self.safe_string(contractConfig, 'exchange')) # Domain must match exactly what server expects for signature validation domain = { 'name': orderDomainName, 'version': orderDomainVersion, 'chainId': chainId, 'verifyingContract': verifyingContract, } # EIP-712 types for orders from https://github.com/Polymarket/clob-order-utils/blob/main/src/exchange.order.const.ts ORDER_STRUCTURE = [ {'name': 'salt', 'type': 'uint256'}, {'name': 'maker', 'type': 'address'}, {'name': 'signer', 'type': 'address'}, {'name': 'taker', 'type': 'address'}, {'name': 'tokenId', 'type': 'uint256'}, {'name': 'makerAmount', 'type': 'uint256'}, {'name': 'takerAmount', 'type': 'uint256'}, {'name': 'expiration', 'type': 'uint256'}, {'name': 'nonce', 'type': 'uint256'}, {'name': 'feeRateBps', 'type': 'uint256'}, {'name': 'side', 'type': 'uint8'}, {'name': 'signatureType', 'type': 'uint8'}, ] # primary type is types[0] => 'primaryType': 'Order' # EIP712Domain shouldn't be included in messageTypes messageTypes: dict = { 'Order': ORDER_STRUCTURE, } signature = self.sign_typed_data(domain, messageTypes, order) order['signature'] = signature return order def build_order(self, symbol: str, type: OrderType, side: OrderSide, amount: float, price: Num = None, params={}) -> dict: """ build a signed order request payload from order parameters https://docs.polymarket.com/developers/CLOB/orders/create-order https://docs.polymarket.com/developers/CLOB/orders/create-order-batch :param str symbol: unified symbol of the market to create an order in :param str type: 'market' or 'limit' :param str side: 'buy' or 'sell' :param float amount: how much you want to trade in units of the base currency :param float [price]: the price at which the order is to be fulfilled, in units of the quote currency, ignored in market orders :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required if market has multiple outcomes) :param str [params.timeInForce]: 'GTC', 'IOC', 'FOK', 'GTD'(default: 'GTC') :param str [params.clientOrderId]: a unique id for the order :param boolean [params.postOnly]: if True, the order will only be posted to the order book and not executed immediately :param number [params.expiration]: expiration timestamp in seconds(default: 30 days from now) :returns dict: request payload with order, owner, orderType, and optional fields """ market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # Use first token ID if multiple outcomes exist tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' buildOrder() requires a token_id parameter for market ' + symbol) # Convert CCXT side to Polymarket side(BUY/SELL) polymarketSide = 'BUY' if (side == 'buy') else 'SELL' # Convert amount and price to strings size = self.number_to_string(amount) priceStr = None if type == 'limit': if price is None: raise ArgumentsRequired(self.id + ' buildOrder() requires a price parameter for limit orders') priceStr = self.number_to_string(price) elif type == 'market': # For market orders, price is optional but recommended # If not provided, we'll try to fetch from orderbook or use params.marketPrice if price is not None: priceStr = self.number_to_string(price) else: # Try to get price from params.marketPrice marketPrice = self.safe_number(params, 'marketPrice') if marketPrice is not None: priceStr = self.number_to_string(marketPrice) # Determine orderType(at top level, not inside order object) # Must be determined before building orderObject to set expiration correctly orderType = self.safe_string(params, 'timeInForce', 'GTC') if type == 'market': # For market orders, use IOC(Immediate-Or-Cancel) by default # IOC allows partial fills, making it more forgiving than FOK(Fill-Or-Kill) # Users can still override with params.timeInForce = 'FOK' if needed orderType = self.safe_string(params, 'timeInForce', 'IOC') # Set expiration BEFORE signing: for non-GTD orders(GTC, FOK, FAK), expiration must be '0' # Only GTD orders should have a timestamp expiration # The signature must match the exact expiration value that will be sent to the API # See https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters orderTypeUpper = orderType.upper() # For non-GTD orders, expiration MUST be '0'(API requirement) # Override any user-provided expiration for non-GTD orders orderParams = self.extend({}, params) if orderTypeUpper == 'GTD': expiration = self.safe_integer(params, 'expiration') if expiration is None: nowSeconds = int(math.floor(self.milliseconds()) / 1000) defaultExpirationDays = self.safe_integer(self.options, 'defaultExpirationDays', 30) expiration = nowSeconds + (defaultExpirationDays * 24 * 60 * 60) else: orderParams['expiration'] = str(expiration) else: # For non-GTD orders, expiration must be 0(will be converted to "0" string in signing) orderParams['expiration'] = 0 # Pass order type to buildAndSignOrder for market order special handling orderParams['orderType'] = type # Build and sign the order with EIP-712(pass market to use fees from market) signedOrder = self.build_and_sign_order(tokenId, polymarketSide, size, priceStr, market, orderParams) # override signedOrder types signedOrder['salt'] = self.parse_to_int(signedOrder['salt']) # integer not string signedOrder['side'] = polymarketSide # string(BUY or SELL) # Get API credentials for owner field apiCredentials = self.get_api_credentials() owner = self.safe_string(apiCredentials, 'apiKey') if owner is None: raise AuthenticationError(self.id + ' buildOrder() requires API credentials(apiKey)') # Build request payload according to API specification # Top-level fields: order, owner, orderType requestPayload: dict = { 'order': signedOrder, 'owner': owner, 'orderType': orderType.upper(), } # Add optional parameters if provided clientOrderId = self.safe_string(params, 'clientOrderId') if clientOrderId is not None: requestPayload['clientOrderId'] = clientOrderId postOnly = self.safe_bool(params, 'postOnly', False) if postOnly: requestPayload['postOnly'] = True return requestPayload def create_order(self, symbol: str, type: OrderType, side: OrderSide, amount: float, price: Num = None, params={}) -> Order: """ create a trade order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/create-order https://github.com/Polymarket/clob-order-utils https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters :param str symbol: unified symbol of the market to create an order in :param str type: 'market' or 'limit' :param str side: 'buy' or 'sell' :param float amount: how much you want to trade in units of the base currency :param float [price]: the price at which the order is to be fulfilled, in units of the quote currency, ignored in market orders :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required if market has multiple outcomes) :param str [params.timeInForce]: 'GTC', 'IOC', 'FOK', 'GTD'(default: 'GTC') :param str [params.clientOrderId]: a unique id for the order :param boolean [params.postOnly]: if True, the order will only be posted to the order book and not executed immediately :param number [params.expiration]: expiration timestamp in seconds(default: 30 days from now) :param number [params.nonce]: order nonce(default: current timestamp) :param number [params.feeRateBps]: fee rate in basis points(default: fetched from API) :returns dict: an `order structure ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Build the order request payload requestPayload = self.build_order(symbol, type, side, amount, price, params) # Extract clientOrderId from request payload for return value clientOrderId = self.safe_string(requestPayload, 'clientOrderId') # Submit order via POST /order endpoint response = self.clob_private_post_order(self.extend(requestPayload, params)) # Response format: # { # "success": boolean, # "errorMsg": string(if error), # "orderId": string, # "orderHashes": string[](if order was marketable) # } success = self.safe_bool(response, 'success', True) if not success: errorMsg = self.safe_string(response, 'errorMsg', 'Unknown error') raise ExchangeError(self.id + ' createOrder() failed: ' + errorMsg) orderId = self.safe_string(response, 'orderID') if orderId is None: raise ExchangeError(self.id + ' createOrder() response missing orderID') market = None if symbol: market = self.market(symbol) # Combine response with order details from requestPayload for parseOrder orderData = self.extend({ 'orderID': orderId, 'clientOrderId': clientOrderId, 'order': requestPayload['order'], # Include the signed order for additional context 'order_type': requestPayload['orderType'], # Include orderType for parseOrder }, response) order = self.parse_order(orderData, market) return order def create_orders(self, orders: List[OrderRequest], params={}) -> List[Order]: """ create multiple trade orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/create-order-batch :param Array orders: list of orders to create, each order should contain the parameters required by createOrder :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: an array of `order structures ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) orderRequests = [] clientOrderIds = [] symbols = [] for i in range(0, len(orders)): order = orders[i] symbol = self.safe_string(order, 'symbol') if symbol is None: raise ArgumentsRequired(self.id + ' createOrders() requires a symbol in each order') type = self.safe_string(order, 'type') side = self.safe_string(order, 'side') amount = self.safe_number(order, 'amount') price = self.safe_number(order, 'price') orderParams = self.safe_dict(order, 'params', {}) # Merge order-level params with top-level params mergedParams = self.extend({}, params, orderParams) # Get token_id from order params, order directly, or it will be resolved in buildOrder tokenId = self.safe_string(orderParams, 'token_id') or self.safe_string(order, 'token_id') if tokenId is not None: mergedParams['token_id'] = tokenId # Get clientOrderId from order params or order directly clientOrderId = self.safe_string(orderParams, 'clientOrderId') or self.safe_string(order, 'clientOrderId') if clientOrderId is not None: mergedParams['clientOrderId'] = clientOrderId # Get timeInForce from order params or order directly timeInForce = self.safe_string(orderParams, 'timeInForce') or self.safe_string(order, 'timeInForce') if timeInForce is not None: mergedParams['timeInForce'] = timeInForce # Build the order request payload using the shared buildOrder function orderRequest = self.build_order(symbol, type, side, amount, price, mergedParams) # Store clientOrderId from request payload for response parsing requestClientOrderId = self.safe_string(orderRequest, 'clientOrderId') clientOrderIds.append(requestClientOrderId) symbols.append(symbol) orderRequests.append(orderRequest) # Submit batch orders via POST /orders endpoint response = self.clob_private_post_orders(self.extend({'orders': orderRequests}, params)) # Response format: array of order responses, each with: # { # "success": boolean, # "errorMsg": string(if error), # "orderId": string, # "orderHashes": string[](if order was marketable) # } result = [] for i in range(0, len(response)): orderResponse = response[i] success = self.safe_bool(orderResponse, 'success', True) if not success: errorMsg = self.safe_string(orderResponse, 'errorMsg', 'Unknown error') raise ExchangeError(self.id + ' createOrders() failed for order ' + i + ': ' + errorMsg) orderId = self.safe_string(orderResponse, 'orderID') if orderId is None: raise ExchangeError(self.id + ' createOrders() response missing orderID for order ' + i) market = None if symbols[i]: market = self.market(symbols[i]) # Combine response with order details from orderRequests for parseOrder orderData = self.extend({ 'orderID': orderId, 'clientOrderId': clientOrderIds[i], 'order': orderRequests[i]['order'], # Include the signed order for additional context 'order_type': orderRequests[i]['orderType'], # Include orderType for parseOrder }, orderResponse) result.append(self.parse_order(orderData, market)) return result def create_market_order(self, symbol: str, side: OrderSide, amount: float, price: Num = None, params={}): """ create a market order :param str symbol: unified symbol of the market to create an order in :param str side: 'buy' or 'sell' :param float amount: how much you want to trade in units of the base currency :param float [price]: ignored for market orders :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: an `order structure ` """ # Use IOC by default for market orders(allows partial fills) # Users can override with params.timeInForce = 'FOK' if they need Fill-Or-Kill behavior return self.create_order(symbol, 'market', side, amount, price, self.extend(params, {'timeInForce': 'IOC'})) def cancel_order(self, id: str, symbol: Str = None, params={}) -> Order: """ cancels an open order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/cancel-order :param str id: order id :param str symbol: unified symbol of the market the order was made in :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: An `order structure ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Based on cancel() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL = "/order") # Response format: {canceled: string[], not_canceled: {order_id -> reason}} # See https://docs.polymarket.com/developers/CLOB/orders/cancel-order response = self.clob_private_delete_order(self.extend({'order_id': id}, params)) canceled = self.safe_list(response, 'canceled', []) notCanceled = self.safe_dict(response, 'not_canceled', {}) # Check if order was successfully canceled isCanceled = False for i in range(0, len(canceled)): if canceled[i] == id: isCanceled = True break if isCanceled: # Order was canceled, parse order from response data market = self.market(symbol) if symbol else None orderData = { 'id': id, 'status': 'canceled', 'info': response, } return self.parse_order(orderData, market) else: # Check if order is in not_canceled map reason = self.safe_string(notCanceled, id) if reason is not None: # Order couldn't be canceled, raise error with reason raise ExchangeError(self.id + ' cancelOrder() failed: ' + reason) else: # Order ID not found in response(shouldn't happen) raise ExchangeError(self.id + ' cancelOrder() unexpected response format') def cancel_orders(self, ids: List[str], symbol: Str = None, params={}) -> List[Order]: """ cancel multiple orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/cancel-order-batch :param str[] ids: order ids :param str symbol: unified symbol of the market the orders were made in :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: an array of `order structures ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Based on cancel_orders() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ORDERS = "/orders") # Response format: {canceled: string[], not_canceled: {order_id -> reason}} # See https://docs.polymarket.com/developers/CLOB/orders/cancel-order-batch response = self.clob_private_delete_orders(self.extend({'order_ids': ids}, params)) canceled = self.safe_list(response, 'canceled', []) notCanceled = self.safe_dict(response, 'not_canceled', {}) market = self.market(symbol) if symbol else None orders: List[Order] = [] # Add canceled orders for i in range(0, len(canceled)): orderId = canceled[i] orderData = { 'id': orderId, 'status': 'canceled', 'info': response, } orders.append(self.parse_order(orderData, market)) # Verify all requested orders are accounted for in the response for i in range(0, len(ids)): orderId = ids[i] isInCanceled = False for j in range(0, len(canceled)): if canceled[j] == orderId: isInCanceled = True break if not isInCanceled and not (orderId in notCanceled): # Order ID not found in response(unexpected) raise ExchangeError(self.id + ' cancelOrders() unexpected response format for order ' + orderId) return orders def cancel_all_orders(self, symbol: Str = None, params={}) -> List[Order]: """ cancel all open orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/cancel-all-orders :param str [symbol]: unified market symbol, only orders in the market of self symbol are cancelled when symbol is not None :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `order structures ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) response if symbol is not None: # Use cancel-market-orders endpoint when symbol is provided # See https://docs.polymarket.com/developers/CLOB/orders/cancel-market-orders market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get condition_id(market ID) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) # Get asset_id from clobTokenIds clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) request: dict = {} if conditionId is not None: request['market'] = conditionId if len(clobTokenIds) > 0: request['asset_id'] = clobTokenIds[0] # Response format: {canceled: string[], not_canceled: {order_id -> reason}} response = self.clob_private_delete_cancel_market_orders(self.extend(request, params)) else: # Use cancel-all endpoint when symbol is None # Based on cancel_all() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ALL = "/cancel-all") # Response format: {canceled: string[], not_canceled: {order_id -> reason}} # See https://docs.polymarket.com/developers/CLOB/orders/cancel-all-orders response = self.clob_private_delete_cancel_all(params) canceled = self.safe_list(response, 'canceled', []) orderMarket = self.market(symbol) if symbol else None orders: List[Order] = [] # Add canceled orders for i in range(0, len(canceled)): orderId = canceled[i] orderData = { 'id': orderId, 'status': 'canceled', 'info': response, } orders.append(self.parse_order(orderData, orderMarket)) return orders def fetch_order(self, id: str, symbol: Str = None, params={}) -> Order: """ fetches information on an order made by the user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/get-order :param str id: order id :param str symbol: unified symbol of the market the order was made in :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: An `order structure ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Based on get_order() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_ORDER = "/data/order/") response = self.clob_private_get_order(self.extend({'order_id': id}, params)) market = self.market(symbol) if symbol else None return self.parse_order(response, market) def fetch_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Order]: """ fetches information on multiple orders made by the user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/orders/get-orders :param str symbol: unified symbol of the market the orders were made in :param int [since]: the earliest time in ms to fetch orders for :param int [limit]: the maximum number of order structures to retrieve :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: filter orders by order id :param str [params.market]: filter orders by market id :param str [params.asset_id]: filter orders by asset id(alias token_id) :returns dict[]: a list of `order structures ` """ self.load_markets() self.ensure_api_credentials(params) request = {} if symbol is not None: market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Filter by condition_id(market) to get all orders for self market # This is more appropriate than filtering by asset_id alone, market can have multiple outcomes conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) if conditionId is not None: request['market'] = conditionId # Also include asset_id for backward compatibility and more specific filtering clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: # The Polymarket L2 getOpenOrders() endpoint filters by asset_id request['asset_id'] = clobTokenIds[0] # Keep backward compatibility for legacy token_id usage request['token_id'] = clobTokenIds[0] id = self.safe_string(params, 'id') if id is not None: request['id'] = id marketId = self.safe_string(params, 'market') if marketId is not None: request['market'] = marketId assetId = self.safe_string_2(params, 'asset_id', 'token_id') if assetId is not None: request['asset_id'] = assetId request['token_id'] = assetId initialCursor = self.safe_string(self.options, 'initialCursor') endCursor = self.safe_string(self.options, 'endCursor') nextCursor = initialCursor ordersResponse: List[Any] = [] while(True): response = self.clob_private_get_orders(self.extend(request, {'next_cursor': nextCursor}, params)) data = self.safe_list(response, 'data', []) ordersResponse = self.array_concat(ordersResponse, data) if limit is not None and len(ordersResponse) >= limit: break nextCursor = self.safe_string(response, 'next_cursor') if nextCursor is None or nextCursor == endCursor: break orderMarket = self.market(symbol) if symbol else None return self.parse_orders(ordersResponse, orderMarket, since, limit) def fetch_open_orders(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Order]: """ fetch all unfilled currently open orders :param str symbol: unified symbol of the market to fetch open orders for :param int [since]: the earliest time in ms to fetch open orders for :param int [limit]: the maximum number of open order structures to retrieve :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict[]: a list of `order structures ` """ # The Polymarket getOpenOrders() endpoint already returns open orders return self.fetch_orders(symbol, since, limit, params) def parse_order(self, order: dict, market: Market = None) -> Order: """ parses an order from the exchange response format :param dict order: order response from the exchange :param dict [market]: market structure :returns dict: an `order structure ` """ # Handle createOrder/createOrders response format: # { # "success": boolean, # "errorMsg": string(if error), # "orderID": string, # "orderHashes": string[](if order was marketable) # } # Or fetchOrder response format(OpenOrder interface): # { # id: string # status: string # owner: string # maker_address: string # market: string # asset_id: string # side: string # original_size: string # size_matched: string # price: string # associate_trades: string[] # outcome: string # created_at: number # seconds # expiration: string # order_type: string # } id = self.safe_string(order, 'id') # Handle createOrder response format(has orderID instead of id) if id is None: id = self.safe_string(order, 'orderID') marketId = self.safe_string(order, 'market') assetId = self.safe_string(order, 'asset_id') if market is None and marketId is not None: market = self.safe_market(marketId, None) symbol = None if market is not None and market['symbol'] is not None: symbol = market['symbol'] elif assetId is not None: symbol = assetId # Handle createOrder response - get side from order object if available sideStr = self.safe_string_lower(order, 'side') # If side is not in order, try to get it from the order object passed in createOrder if sideStr is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: sideStr = self.safe_string_lower(orderObj, 'side') side = sideStr if (sideStr == 'buy' or sideStr == 'sell') else None orderType = self.safe_string(order, 'order_type') # Handle createOrder response - get orderType from order object if available if orderType is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: orderType = self.safe_string(orderObj, 'orderType') # Also check at top level(from requestPayload) if orderType is None: orderType = self.safe_string(order, 'orderType') # Normalize orderType to lowercase for consistent parsing if orderType is not None: orderType = orderType.lower() # Amounts amount = self.safe_number(order, 'original_size') # Handle createOrder response - get amount from order object if available if amount is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: amount = self.safe_number(orderObj, 'size') filled = self.safe_number(order, 'size_matched') remaining = self.safe_number(order, 'remaining_size') if remaining is None and amount is not None and filled is not None: remaining = amount - filled # Price price = self.safe_number(order, 'price') # Handle createOrder response - get price from order object if available if price is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: price = self.safe_number(orderObj, 'price') # Status statusStr = self.safe_string(order, 'status', '') status = self.parse_order_status(statusStr) # Timestamps(created_at is seconds) createdAt = self.safe_integer(order, 'created_at') timestamp = createdAt * 1000 if (createdAt is not None) else None # Get clientOrderId from order or from the order object clientOrderId = self.safe_string(order, 'clientOrderId') if clientOrderId is None: orderObj = self.safe_dict(order, 'order') if orderObj is not None: clientOrderId = self.safe_string(orderObj, 'clientOrderId') # No explicit updated_at in interface; leave lastTradeTimestamp None return self.safe_order({ 'id': id, 'clientOrderId': clientOrderId, 'info': order, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp) if timestamp else None, 'lastTradeTimestamp': None, 'status': status, 'symbol': symbol, 'type': self.parse_order_type(orderType), 'timeInForce': self.parse_time_in_force(orderType), 'side': side, 'price': price, 'amount': amount, 'cost': None, 'average': None, 'filled': filled, 'remaining': remaining, 'fee': None, }, market) def parse_order_status(self, status: Str) -> Str: """ parse the status of an order :param str status: order status from exchange :returns str: a unified order status """ if status is None or status == '': return 'open' # Default to 'open' if no status is provided statuses: dict = { # https://docs.polymarket.com/developers/CLOB/orders/create-order#status 'matched': 'closed', # order placed and matched with an existing resting order 'live': 'open', # order placed and resting on the book 'delayed': 'open', # order marketable, but subject to matching delay 'unmatched': 'open', # order marketable, but failure delaying, placement successful 'canceled': 'canceled', # CCXT unified status for canceled orders } normalizedStatus = status.lower() return self.safe_string(statuses, normalizedStatus, normalizedStatus) def parse_order_type(self, type: Str) -> Str: types: dict = { 'fok': 'market', # Fill-Or-Kill: market order 'fak': 'market', # Fill-And-Kill: market order 'ioc': 'market', # Immediate-Or-Cancel: market order 'gtc': 'limit', # Good-Til-Cancelled: limit order 'gtd': 'limit', # Good-Til-Date: limit order } return self.safe_string(types, type, 'limit') def parse_time_in_force(self, timeInForce: Str) -> Str: if timeInForce is None: return None timeInForces: dict = { 'fok': 'FOK', # Fill-Or-Kill 'fak': 'FAK', # Fill-And-Kill 'ioc': 'IOC', # Immediate-Or-Cancel 'gtc': 'GTC', # Good-Til-Cancelled 'gtd': 'GTD', # Good-Til-Date } normalized = timeInForce.lower() mapped = self.safe_string(timeInForces, normalized) return mapped is not mapped if None else timeInForce.upper() def fetch_time(self, params={}) -> Int: """ fetches the current integer timestamp in milliseconds from the exchange server https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L178 :param dict [params]: extra parameters specific to the exchange API endpoint :returns int: the current integer timestamp in milliseconds from the exchange server """ # Based on get_server_time() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L178 response = self.clob_public_get_time(params) # Response format: timestamp in seconds(Unix timestamp) # Convert to milliseconds for CCXT standard timestamp = self.safe_integer(response, 'timestamp') if timestamp is not None: return timestamp * 1000 # Convert seconds to milliseconds # Fallback: if response is just a number if isinstance(response, numbers.Real): return response * 1000 # Fallback: use current time if server time not available return self.milliseconds() def fetch_status(self, params={}): """ the latest known information on the availability of the exchange API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: a `status structure ` """ # Based on get_ok() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 try: self.clob_public_get_ok(params) return { 'status': 'ok', 'updated': None, 'eta': None, 'url': None, } except Exception as e: return { 'status': 'error', 'updated': None, 'eta': None, 'url': None, } def fetch_trading_fee(self, symbol: str, params={}) -> TradingFeeInterface: """ fetches the trading fee for a market https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param str symbol: unified symbol of the market to fetch the fee for :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required if not in market info) :returns dict: a `fee structure ` """ self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Get token ID from params or market info tokenId = self.safe_string(params, 'token_id') if tokenId is None: clobTokenIds = self.safe_value(marketInfo, 'clobTokenIds', []) if len(clobTokenIds) > 0: tokenId = clobTokenIds[0] else: raise ArgumentsRequired(self.id + ' fetchTradingFee() requires a token_id parameter for market ' + symbol) # Based on get_fee_rate() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = self.clob_public_get_fee_rate(self.extend({'token_id': tokenId}, params)) # Response format: {"fee_rate": "0.02"} or {"fee_rate_bps": 200}(basis points) feeRate = self.safe_string(response, 'fee_rate') feeRateBps = self.safe_integer(response, 'fee_rate_bps') maker: Num = None taker: Num = None if feeRate is not None: fee = self.parse_number(feeRate) maker = fee taker = fee elif feeRateBps is not None: # Convert basis points to percentage(200 bps = 2% = 0.02) fee = self.parse_number(feeRateBps) / 10000 maker = fee taker = fee else: # Default fee from describe() if not available maker = self.safe_number(self.fees['trading'], 'maker') taker = self.safe_number(self.fees['trading'], 'taker') # Ensure we have valid numbers(fallback to default if None) makerFee: Num = maker is not maker if None else self.parse_number('0.02') takerFee: Num = taker is not taker if None else self.parse_number('0.02') result: TradingFeeInterface = { 'info': response, 'symbol': symbol, 'maker': makerFee, 'taker': takerFee, 'percentage': True, 'tierBased': False, } return result def fetch_open_interest(self, symbol: str, params={}): """ retrieves the open interest of a market https://docs.polymarket.com/api-reference/misc/get-open-interest :param str symbol: unified CCXT market symbol :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict} an open interest structure{@link https://docs.ccxt.com/#/?id=open-interest-structure: """ self.load_markets() market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) # API expects market of condition IDs request: dict = { 'market': [conditionId], } response = self.data_public_get_open_interest(self.extend(request, params)) return self.parse_open_interest(response, market) def parse_open_interest(self, interest: dict, market: Market = None): """ parses open interest data from the exchange response format :param dict interest: open interest data from the exchange :param dict [market]: the market self open interest is for :returns dict} an open interest structure{@link https://docs.ccxt.com/#/?id=open-interest-structure: """ # Polymarket Data API /oi response format # Response is an array of objects with market(condition ID) and value # Example response structure: # [ # { # "market": "0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917", # "value": 123 # } # ] timestamp = self.milliseconds() # Handle array response openInterestData: dict = {} if isinstance(interest, list): # For single symbol query, get the first item if len(interest) > 0: openInterestData = interest[0] elif isinstance(interest, dict) and interest != None: # Fallback: handle object response if API changes openInterestData = interest # Extract open interest value from the response # API returns "value" field which represents the open interest value openInterestValue = self.safe_number(openInterestData, 'value', 0) # For Polymarket, value is typically in USDC, so we use it amount and value # If we need to distinguish, we could parse additional fields if available return self.safe_open_interest({ 'symbol': market['symbol'] if market else None, 'openInterestAmount': openInterestValue, # Using value since API only provides value 'openInterestValue': openInterestValue, 'timestamp': timestamp, 'datetime': self.iso8601(timestamp), 'info': interest, }, market) def fetch_my_trades(self, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ fetch all trades made by the user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param str symbol: unified symbol of the market to fetch trades for :param int [since]: the earliest time in ms to fetch trades for :param int [limit]: the maximum number of trades structures to retrieve :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: filter trades by market(condition_id) :param str [params.asset_id]: filter trades by asset ID :param str [params.id]: filter by trade id :param str [params.maker_address]: filter by maker address :param str [params.before]: pagination cursor(see API docs) :param str [params.after]: pagination cursor(see API docs) :param str [params.next_cursor]: pagination cursor(default: "MA==") :returns Trade[]: a list of `trade structures ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) request: dict = {} market = None if symbol is not None: market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) # Filter by condition_id(market) to get all trades for self market # Don't automatically add asset_id filter would restrict to only one outcome conditionId = self.safe_string(marketInfo, 'condition_id', self.safe_string(market, 'id')) if conditionId is not None: request['market'] = conditionId # Backward compatibility: token_id alias to asset_id tokenId = self.safe_string(params, 'token_id') if tokenId is not None: request['asset_id'] = tokenId marketId = self.safe_string(params, 'market') if marketId is not None: request['market'] = marketId assetId = self.safe_string_2(params, 'asset_id', 'assetId') if assetId is not None: request['asset_id'] = assetId id = self.safe_string(params, 'id') if id is not None: request['id'] = id makerAddress = self.safe_string_2(params, 'maker_address', 'makerAddress') if makerAddress is not None: request['maker_address'] = makerAddress before = self.safe_string(params, 'before') if before is not None: request['before'] = before after = self.safe_string(params, 'after') if after is not None: request['after'] = after if since is not None: # Map ccxt since to Polymarket's "after" cursor using seconds request['after'] = self.number_to_string(int(math.floor(since / 1000))) if limit is not None: request['limit'] = limit results: List[Any] = [] initialCursor = self.safe_string(self.options, 'initialCursor') endCursor = self.safe_string(self.options, 'endCursor') next_cursor = initialCursor while(next_cursor != endCursor): response = self.clob_private_get_trades(self.extend(request, {'next_cursor': next_cursor}, params)) next_cursor = self.safe_string(response, 'next_cursor', endCursor) data = self.safe_list(response, 'data', []) or [] results = self.array_concat(results, data) if limit is not None and len(results) >= limit: break return self.parse_trades(results, market, since, limit) def fetch_user_trades(self, user: str, symbol: Str = None, since: Int = None, limit: Int = None, params={}) -> List[Trade]: """ fetch trades for a specific user https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets :param str user: user address(0x-prefixed, 40 hex chars) :param str [symbol]: unified symbol of the market to fetch trades for :param int [since]: timestamp in ms of the earliest trade to fetch :param int [limit]: the maximum amount of trades to fetch(default: 100, max: 10000) :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.offset]: offset for pagination(default: 0, max: 10000) :param boolean [params.takerOnly]: if True, returns only trades where the user is the taker(default: True) :param str [params.side]: filter by side: 'BUY' or 'SELL' :param str[] [params.market]: comma-separated list of condition IDs(mutually exclusive with symbol) :param int[] [params.eventId]: comma-separated list of event IDs(mutually exclusive with market) :returns Trade[]: a list of `trade structures ` """ self.load_markets() request: dict = { 'user': user, } market = None if symbol is not None: market = self.market(symbol) marketInfo = self.safe_dict(market, 'info', {}) conditionId = self.safe_string(marketInfo, 'condition_id', market['id']) request['market'] = [conditionId] marketParam = self.safe_value(params, 'market') if marketParam is not None: # Convert to array if it's a string or single value if isinstance(marketParam, list): request['market'] = marketParam else: request['market'] = [marketParam] eventId = self.safe_value(params, 'eventId') if eventId is not None: if isinstance(eventId, list): request['eventId'] = eventId else: request['eventId'] = [eventId] if limit is not None: request['limit'] = min(limit, 10000) # Cap at max 10000 offset = self.safe_integer(params, 'offset') if offset is not None: request['offset'] = offset takerOnly = self.safe_bool(params, 'takerOnly', True) request['takerOnly'] = takerOnly side = self.safe_string_upper(params, 'side') if side is not None: request['side'] = side response = self.data_public_get_trades(self.extend(request, self.omit(params, ['market', 'eventId', 'offset', 'takerOnly', 'side']))) tradesData = [] if isinstance(response, list): tradesData = response else: dataList = self.safe_list(response, 'data', []) if dataList is not None: tradesData = dataList return self.parse_trades(tradesData, market, since, limit) def fetch_balance(self, params={}): """ fetches balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/clients/methods-l2#getbalanceallowance :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_type]: asset type: 'COLLATERAL'(default) or 'CONDITIONAL' :param str [params.token_id]: token ID, default: from options.defaultTokenId) :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: a `balance structure ` """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Default asset_type to COLLATERAL if not provided assetType = self.safe_string(params, 'asset_type', 'COLLATERAL') params['asset_type'] = assetType # Use signature_type from params or fall back to options signatureType = self.get_signature_type(params) request: dict = { 'asset_type': assetType, } if signatureType is not None: request['signature_type'] = signatureType tokenId = self.safe_string(params, 'token_id') if tokenId is None: defaultTokenId = self.safe_string(self.options, 'defaultTokenId') if defaultTokenId is not None: request['token_id'] = defaultTokenId else: request['token_id'] = tokenId # Fetch balance and allowance from CLOB endpoint clobResponse = self.clob_private_get_balance_allowance(request) # # { # "balance": "1000000", # "allowance": "0" # } # balance = self.safe_string(clobResponse, 'balance') allowance = self.safe_string(clobResponse, 'allowance') collateral = self.safe_string(self.options, 'defaultCollateral', 'USDC') # Convert CLOB balance and allowance(6 decimals) to standard units collateralTotalValue = None collateralUsedValue = None collateralFreeValue = None if balance is not None: parsedBalance = self.parse_number(balance) if parsedBalance is not None: collateralTotalValue = parsedBalance / 1000000 if allowance is not None: parsedAllowance = self.parse_number(allowance) if parsedAllowance is not None: collateralUsedValue = parsedAllowance / 1000000 # Calculate free balance: total - used(allowance) if collateralTotalValue is not None and collateralUsedValue is not None: collateralFreeValue = collateralTotalValue - collateralUsedValue elif collateralTotalValue is not None: collateralFreeValue = collateralTotalValue result: dict = { 'info': clobResponse, } if collateralTotalValue is not None: account = self.account() account['total'] = collateralTotalValue if collateralFreeValue is not None: account['free'] = collateralFreeValue if collateralUsedValue is not None: account['used'] = collateralUsedValue result[collateral] = account return self.safe_balance(result) def get_notifications(self, params={}): """ fetches notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Use signature_type from params or fall back to options signatureType = self.get_signature_type(params) request: dict = {} if signatureType is not None: request['signature_type'] = signatureType # Based on get_notifications() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = self.clob_private_get_notifications(self.extend(request, params)) return response def drop_notifications(self, params={}): """ drops notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.notification_id]: specific notification ID to drop(optional) :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Use signature_type from params or fall back to options signatureType = self.get_signature_type(params) request: dict = {} if signatureType is not None: request['signature_type'] = signatureType # Based on drop_notifications() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py response = self.clob_private_delete_notifications(self.extend(request, params)) return response def get_balance_allowance(self, params={}): """ fetches balance and allowance for the authenticated user(alias for fetchBalance) https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/clients/methods-l2#getbalanceallowance :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.asset_type]: asset type: 'COLLATERAL'(default) or 'CONDITIONAL' :param str [params.token_id]: token ID, default: from options.defaultTokenId) :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Alias for fetchBalance, but returns raw response # Use signature_type from params or fall back to options if self.safe_integer(params, 'signature_type') is None: signatureType = self.get_signature_type(params) if signatureType is not None: params['signature_type'] = signatureType # Default asset_type to COLLATERAL if not provided(for USDC balance) assetType = self.safe_string(params, 'asset_type', 'COLLATERAL') params['asset_type'] = assetType tokenId = self.safe_string(params, 'token_id') if tokenId is None: defaultTokenId = self.safe_string(self.options, 'defaultTokenId') if defaultTokenId is not None: params['token_id'] = defaultTokenId else: params['token_id'] = tokenId return self.clob_private_get_balance_allowance(params) def update_balance_allowance(self, params={}): """ updates balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) # Based on update_balance_allowance() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py # Use signature_type from params or fall back to options if self.safe_integer(params, 'signature_type') is None: signatureType = self.get_signature_type(params) if signatureType is not None: params['signature_type'] = signatureType response = self.clob_private_put_balance_allowance(params) return response def is_order_scoring(self, params={}): """ checks if an order is currently scoring https://docs.polymarket.com/developers/CLOB/orders/check-scoring#check-order-reward-scoring :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID to check(required) :returns dict: response from the exchange indicating if order is scoring """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) orderId = self.safe_string(params, 'order_id') if orderId is None: raise ArgumentsRequired(self.id + ' isOrderScoring() requires an order_id parameter') response = self.clob_private_get_is_order_scoring(params) # Response: {scoring: boolean} return response def are_orders_scoring(self, params={}): """ checks if multiple orders are currently scoring https://docs.polymarket.com/developers/CLOB/orders/check-scoring#check-order-reward-scoring :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.order_ids]: array of order IDs to check(required) :returns dict: response from the exchange indicating which orders are scoring """ self.load_markets() # Ensure API credentials are generated(lazy generation) self.ensure_api_credentials(params) orderIds = self.safe_value_2(params, 'order_ids', 'orderIds') if orderIds is None or not isinstance(orderIds, list): raise ArgumentsRequired(self.id + ' areOrdersScoring() requires an order_ids parameter(array of order IDs)') response = self.clob_private_post_are_orders_scoring(self.extend({'orderIds': orderIds}, params)) # Response: {orderId: boolean, ...} return response def clob_public_get_markets(self, params={}): """ fetches markets from CLOB API(matches clob-client getMarkets()) https://github.com/Polymarket/clob-client/blob/main/src/client.ts :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.next_cursor]: pagination cursor(default: options.initialCursor) :returns dict: response from the exchange """ # Pass api ['clob', 'public'] to match the expected format # The api parameter should be an array [api_type, access_level] for exchanges with multiple API types return self.request('markets', ['clob', 'public'], 'GET', self.extend({'api_type': 'clob'}, params)) def gamma_public_get_markets(self, params={}): """ fetches markets from Gamma API :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # Pass api ['gamma', 'public'] to match the expected format # The api parameter should be an array [api_type, access_level] for exchanges with multiple API types return self.request('markets', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) def gamma_public_get_markets_id(self, params={}): """ fetches a specific market by ID from Gamma API :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetMarketsId() requires an id parameter') path = 'markets/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def gamma_public_get_markets_id_tags(self, params={}): """ fetches tags for a specific market by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the market ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetMarketsIdTags() requires an id parameter') path = 'markets/' + self.encode_uri_component(id) + '/tags' remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def gamma_public_get_markets_slug_slug(self, params={}): """ fetches a specific market by slug from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.slug]: the market slug(required) :returns dict: response from the exchange """ slug = self.safe_string(params, 'slug') if slug is None: raise ArgumentsRequired(self.id + ' gammaPublicGetMarketsSlugSlug() requires a slug parameter') path = 'markets/slug/' + self.encode_uri_component(slug) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'slug')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def gamma_public_get_events(self, params={}): """ fetches events from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :param str [params.category]: filter by category :param str [params.slug]: filter by slug :returns dict: response from the exchange """ return self.request('events', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) def gamma_public_get_events_id(self, params={}): """ fetches a specific event by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the event ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetEventsId() requires an id parameter') path = 'events/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def gamma_public_get_series(self, params={}): """ fetches series from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :param str [params.category]: filter by category :param str [params.slug]: filter by slug :returns dict: response from the exchange """ return self.request('series', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) def gamma_public_get_series_id(self, params={}): """ fetches a specific series by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the series ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetSeriesId() requires an id parameter') path = 'series/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def gamma_public_get_search(self, params={}): """ performs a full-text search across events, tags, and user profiles from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.q]: search query(required) :param str [params.type]: filter by type: 'event', 'tag', 'user', etc. :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ q = self.safe_string(params, 'q') if q is None: raise ArgumentsRequired(self.id + ' gammaPublicGetSearch() requires a q(query) parameter') return self.request('search', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) def gamma_public_get_comments(self, params={}): """ fetches comments from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.event_id]: filter by event ID :param str [params.series_id]: filter by series ID :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ return self.request('comments', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) def gamma_public_get_comments_id(self, params={}): """ fetches a specific comment by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the comment ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetCommentsId() requires an id parameter') path = 'comments/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def gamma_public_get_sports(self, params={}): """ fetches sports data from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.league]: filter by league :param str [params.team]: filter by team :param int [params.limit]: maximum number of results to return :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ return self.request('sports', ['gamma', 'public'], 'GET', self.extend({'api_type': 'gamma'}, params)) def gamma_public_get_sports_id(self, params={}): """ fetches a specific sport/team by ID from Gamma API https://docs.polymarket.com/developers/gamma-markets-api/fetch-markets-guide :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.id]: the sport/team ID(required) :returns dict: response from the exchange """ id = self.safe_string(params, 'id') if id is None: raise ArgumentsRequired(self.id + ' gammaPublicGetSportsId() requires an id parameter') path = 'sports/' + self.encode_uri_component(id) remainingParams = self.extend({'api_type': 'gamma'}, self.omit(params, 'id')) return self.request(path, ['gamma', 'public'], 'GET', remainingParams) def data_public_get_positions(self, params={}): """ fetches current positions for a user from Data-API https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :param str[] [params.market]: comma-separated list of condition IDs(mutually exclusive with eventId) :param int[] [params.eventId]: comma-separated list of event IDs(mutually exclusive with market) :param number [params.sizeThreshold]: minimum size threshold(default: 1) :param boolean [params.redeemable]: filter by redeemable positions(default: False) :param boolean [params.mergeable]: filter by mergeable positions(default: False) :param int [params.limit]: maximum number of results(default: 100, max: 500) :param int [params.offset]: offset for pagination(default: 0, max: 10000) :param str [params.sortBy]: sort field: CURRENT, INITIAL, TOKENS, CASHPNL, PERCENTPNL, TITLE, RESOLVING, PRICE, AVGPRICE(default: TOKENS) :param str [params.sortDirection]: sort direction: ASC, DESC(default: DESC) :param str [params.title]: filter by title(max length: 100) :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetPositions() requires a user parameter') return self.request('positions', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_trades(self, params={}): """ fetches trades for a user or markets from Data-API https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(optional, filter by user) :param str[] [params.market]: comma-separated list of condition IDs(optional, filter by markets) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ return self.request('trades', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_activity(self, params={}): """ fetches user activity from Data-API https://docs.polymarket.com/api-reference/core/get-user-activity :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetActivity() requires a user parameter') return self.request('activity', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_holders(self, params={}): """ fetches top holders for markets from Data-API https://docs.polymarket.com/api-reference/core/get-top-holders-for-markets :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.market]: comma-separated list of condition IDs(required) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :returns dict: response from the exchange """ market = self.safe_string(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' dataPublicGetHolders() requires a market parameter') return self.request('holders', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_total_value(self, params={}): """ fetches total value of a user's positions from Data-API https://docs.polymarket.com/api-reference/core/get-total-value-of-a-users-positions :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetTotalValue() requires a user parameter') return self.request('value', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_closed_positions(self, params={}): """ fetches closed positions for a user from Data-API https://docs.polymarket.com/api-reference/core/get-closed-positions-for-a-user :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :param str[] [params.market]: comma-separated list of condition IDs(mutually exclusive with eventId) :param int[] [params.eventId]: comma-separated list of event IDs(mutually exclusive with market) :param int [params.limit]: maximum number of results :param int [params.offset]: offset for pagination :param str [params.sortBy]: sort field :param str [params.sortDirection]: sort direction: ASC, DESC :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetClosedPositions() requires a user parameter') return self.request('closed-positions', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_traded(self, params={}): """ fetches total markets a user has traded from Data-API https://docs.polymarket.com/api-reference/misc/get-total-markets-a-user-has-traded :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.user]: user address(required) :returns dict: response from the exchange """ user = self.safe_string(params, 'user') if user is None: raise ArgumentsRequired(self.id + ' dataPublicGetTraded() requires a user parameter') return self.request('traded', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def data_public_get_open_interest(self, params={}): """ fetches open interest from Data-API https://docs.polymarket.com/api-reference/misc/get-open-interest :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.market]: array of condition IDs(required) :returns dict: response from the exchange """ market = self.safe_value(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' dataPublicGetOpenInterest() requires a market parameter') # Convert market to array if it's a single string marketArray: List[str] = [] if isinstance(market, list): marketArray = market elif isinstance(market, str): marketArray = [market] else: raise ArgumentsRequired(self.id + ' dataPublicGetOpenInterest() requires market to be a string or array of condition IDs') # API expects market in query params requestParams = self.extend({'market': marketArray}, self.omit(params, 'market')) return self.request('oi', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, requestParams)) def data_public_get_live_volume(self, params={}): """ fetches live volume for an event from Data-API https://docs.polymarket.com/api-reference/misc/get-live-volume-for-an-event :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.eventId]: event ID(required) :returns dict: response from the exchange """ eventId = self.safe_integer(params, 'eventId') if eventId is None: raise ArgumentsRequired(self.id + ' dataPublicGetLiveVolume() requires an eventId parameter') return self.request('live-volume', ['data', 'public'], 'GET', self.extend({'api_type': 'data'}, params)) def bridge_public_get_supported_assets(self, params={}): """ fetches supported assets for bridging from Bridge API https://docs.polymarket.com/developers/misc-endpoints/bridge-supported-assets :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ return self.request('supported-assets', ['bridge', 'public'], 'GET', self.extend({'api_type': 'bridge'}, params)) def bridge_public_post_deposit(self, params={}): """ creates deposit addresses for bridging assets to Polymarket https://docs.polymarket.com/developers/misc-endpoints/bridge-deposit :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.address]: Polymarket wallet address(required) :returns dict: response from the exchange """ address = self.safe_string(params, 'address') if address is None: raise ArgumentsRequired(self.id + ' bridgePublicPostDeposit() requires an address parameter') body = self.json({'address': address}) remainingParams = self.extend({'api_type': 'bridge'}, self.omit(params, 'address')) return self.request('deposit', ['bridge', 'public'], 'POST', remainingParams, None, body) def create_deposit_address(self, code: str, params={}): """ create a deposit address for bridging assets to Polymarket https://docs.polymarket.com/developers/misc-endpoints/bridge-deposit :param str code: unified currency code :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.address]: Polymarket wallet address(required if not set in options) :returns dict: an `address structure ` """ # Get address from params or use default from options address = self.safe_string(params, 'address') if address is None: # Try to get from options or raise error address = self.safe_string(self.options, 'address') if address is None: raise ArgumentsRequired(self.id + ' createDepositAddress() requires an address parameter or address in options') response = self.bridge_public_post_deposit(self.extend({'address': address}, params)) # Response format: {address: "...", depositAddresses: [{chainId, chainName, tokenAddress, tokenSymbol, depositAddress}, ...]} depositAddresses = self.safe_list(response, 'depositAddresses', []) # Find the deposit address for the requested currency code # For Polymarket, all deposits are converted to USDC.e, but we can filter by tokenSymbol currency = self.currency(code) depositAddress = None for i in range(0, len(depositAddresses)): addr = depositAddresses[i] tokenSymbol = self.safe_string(addr, 'tokenSymbol') if tokenSymbol and tokenSymbol.upper() == currency['code'].upper(): depositAddress = self.safe_string(addr, 'depositAddress') break # If not found, return the first deposit address(default to USDC) if depositAddress is None and len(depositAddresses) > 0: depositAddress = self.safe_string(depositAddresses[0], 'depositAddress') return { 'currency': code, 'address': depositAddress, 'tag': None, 'info': response, } def clob_public_get_orderbook_token_id(self, params={}): """ fetches orderbook for a specific token ID from CLOB API :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetOrderbookTokenId() requires a token_id parameter') # Note: CLOB API uses /book endpoint with token_id parameter, not /orderbook/{token_id} # See https://docs.polymarket.com/developers/CLOB/prices-books/get-book remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('book', ['clob', 'public'], 'GET', remainingParams) def clob_public_post_books(self, params={}): """ fetches order books for multiple token IDs from CLOB API https://docs.polymarket.com/api-reference/orderbook/get-multiple-order-books-summaries-by-request :param dict [params]: extra parameters specific to the exchange API endpoint :param Array [params.requests]: array of {token_id, limit?} objects(required) :returns dict: response from the exchange """ requests = self.safe_value(params, 'requests') if requests is None or not isinstance(requests, list) or len(requests) == 0: raise ArgumentsRequired(self.id + ' clobPublicPostBooks() requires a requests parameter(array of {token_id, limit?} objects)') # Note: REST API endpoint format: POST /books with JSON body # See https://docs.polymarket.com/api-reference/orderbook/get-multiple-order-books-summaries-by-request # Request body: [{token_id: "..."}, {token_id: "...", limit: 10}, ...] # Response format: array of order book objects, each with asset_id, bids, asks, etc. body = self.json(requests) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'requests')) return self.request('books', ['clob', 'public'], 'POST', remainingParams, None, body) def clob_public_get_market_trades_events(self, params={}): """ fetches market trade events for a specific condition ID from CLOB API https://docs.polymarket.com/developers/CLOB/clients/methods-public#getmarkettradesevents https://docs.polymarket.com/developers/CLOB/trades/trades-data-api :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.condition_id]: the condition ID(market ID) for the market :param int [params.limit]: the maximum number of trades to fetch(default: 100, max: 500) :param int [params.offset]: number of trades to skip before starting to return results(default: 0) :param boolean [params.takerOnly]: if True, returns only trades where the user is the taker(default: True) :param str [params.side]: filter by side: 'BUY' or 'SELL' :returns dict: response from the exchange """ conditionId = self.safe_string(params, 'condition_id') if conditionId is None: raise ArgumentsRequired(self.id + ' clobPublicGetMarketTradesEvents() requires a condition_id parameter') # Note: CLOB REST API endpoint format: /trades?market={condition_id} # See https://docs.polymarket.com/developers/CLOB/trades/trades-data-api # The client SDK method getMarketTradesEvents() uses a different endpoint, but the REST API uses /trades request: dict = { 'market': conditionId, } remainingParams = self.omit(params, 'condition_id') # Add optional parameters limit = self.safe_integer(remainingParams, 'limit') if limit is not None: request['limit'] = limit offset = self.safe_integer(remainingParams, 'offset') if offset is not None: request['offset'] = offset takerOnly = self.safe_bool(remainingParams, 'takerOnly') if takerOnly is not None: request['takerOnly'] = takerOnly side = self.safe_string(remainingParams, 'side') if side is not None: request['side'] = side # Add any other remaining params finalParams = self.extend({'api_type': 'clob'}, self.extend(request, self.omit(remainingParams, ['limit', 'offset', 'takerOnly', 'side']))) return self.request('trades', ['clob', 'public'], 'GET', finalParams) def clob_public_get_prices_history(self, params={}): """ fetches historical price data for a token from CLOB API https://docs.polymarket.com/developers/CLOB/clients/methods-public#getpriceshistory :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: the token ID(market parameter) :param str [params.interval]: the time interval: "max", "1w", "1d", "6h", "1h" :param int [params.startTs]: timestamp in seconds of the earliest candle to fetch :param int [params.endTs]: timestamp in seconds of the latest candle to fetch :param number [params.fidelity]: data fidelity/quality :returns dict: response from the exchange """ market = self.safe_string(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' clobPublicGetPricesHistory() requires a market(token_id) parameter') # Note: REST API endpoint format: /prices-history # See https://docs.polymarket.com/developers/CLOB/timeseries # Required: market # Time component(mutually exclusive): either(startTs and endTs) OR interval # Optional: fidelity # Response format: {"history": [{"t": timestamp, "p": price}, ...]} request: dict = { 'market': market, } # Add time component - either startTs/endTs OR interval(mutually exclusive) startTs = self.safe_integer(params, 'startTs') endTs = self.safe_integer(params, 'endTs') interval = self.safe_string(params, 'interval') if startTs is not None or endTs is not None: # Use startTs/endTs when provided if startTs is not None: request['startTs'] = startTs if endTs is not None: request['endTs'] = endTs elif interval is not None: # Use interval when startTs/endTs are not provided request['interval'] = interval # Add optional fidelity parameter fidelity = self.safe_number(params, 'fidelity') if fidelity is not None: finalFidelity = fidelity # Polymarket enforces minimum fidelity per interval(e.g. interval=1m requires fidelity>=10) intervalForFidelity = self.safe_string(request, 'interval') if intervalForFidelity == '1m': finalFidelity = max(10, finalFidelity) request['fidelity'] = finalFidelity remainingParams = self.extend({'api_type': 'clob'}, self.extend(request, self.omit(params, ['market', 'startTs', 'endTs', 'fidelity', 'interval']))) return self.request('prices-history', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_time(self, params={}): """ fetches the current server timestamp from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L178 :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # Based on get_server_time() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(TIME = "/time") return self.request('time', ['clob', 'public'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_public_get_ok(self, params={}): """ health check endpoint to confirm server is up https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # Based on get_ok() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py#L170 return self.request('', ['clob', 'public'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_public_get_fee_rate(self, params={}): """ fetches the fee rate for a token from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetFeeRate() requires a token_id parameter') # Based on get_fee_rate() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_FEE_RATE = "/fee-rate") remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('fee-rate', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_price(self, params={}): """ fetches the market price for a specific token and side from CLOB API https://docs.polymarket.com/api-reference/pricing/get-market-price :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :param str [params.side]: the side: 'BUY' or 'SELL'(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetPrice() requires a token_id parameter') side = self.safe_string(params, 'side') if side is None: raise ArgumentsRequired(self.id + ' clobPublicGetPrice() requires a side parameter(BUY or SELL)') # Note: REST API endpoint format: /price?token_id={token_id}&side={side} # See https://docs.polymarket.com/api-reference/pricing/get-market-price remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('price', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_prices(self, params={}): """ fetches market prices for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch prices for :param str [params.side]: the side: 'BUY' or 'SELL'(required if token_ids provided) :returns dict: response from the exchange """ # Note: REST API endpoint format: /prices?token_id={token_id1,token_id2,...} # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices # Response format: {[token_id]: {BUY: "price", SELL: "price"}, ...} # The endpoint returns both BUY and SELL prices for each token_id remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('prices', ['clob', 'public'], 'GET', remainingParams) def clob_public_post_prices(self, params={}): """ fetches market prices for specified tokens and sides via POST request https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request :param dict [params]: extra parameters specific to the exchange API endpoint :param Array [params.requests]: array of {token_id, side} objects(required) :returns dict: response from the exchange """ requests = self.safe_value(params, 'requests') if requests is None: raise ArgumentsRequired(self.id + ' clobPublicPostPrices() requires a requests parameter(array of {token_id, side} objects)') # Note: REST API endpoint format: POST /prices with JSON body # See https://docs.polymarket.com/api-reference/pricing/get-multiple-market-prices-by-request # Body format: [{"token_id": "1234567890", "side": "BUY"}, {"token_id": "1234567890", "side": "SELL"}] # Response format: {[token_id]: {BUY: "price", SELL: "price"}, ...} body = self.json(requests) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'requests')) return self.request('prices', ['clob', 'public'], 'POST', remainingParams, None, body) def clob_public_get_midpoint(self, params={}): """ fetches the midpoint price for a specific token from CLOB API https://docs.polymarket.com/api-reference/pricing/get-midpoint-price :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetMidpoint() requires a token_id parameter') # Note: REST API endpoint format: /midpoint?token_id={token_id} # See https://docs.polymarket.com/api-reference/pricing/get-midpoint-price remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('midpoint', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_midpoints(self, params={}): """ fetches midpoint prices for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/pricing/get-midpoint-prices :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch midpoints for(required) :returns dict: response from the exchange """ tokenIds = self.safe_value(params, 'token_ids') if tokenIds is None or not isinstance(tokenIds, list) or len(tokenIds) == 0: raise ArgumentsRequired(self.id + ' clobPublicGetMidpoints() requires a token_ids parameter(array of token IDs)') # Note: REST API endpoint format: POST /midpoints with JSON body # See https://docs.polymarket.com/api-reference/pricing/get-midpoint-prices # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: {[token_id]: "midpoint", ...} body: List[Any] = [] for i in range(0, len(tokenIds)): body.append({'token_id': tokenIds[i]}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'token_ids')) return self.request('midpoints', ['clob', 'public'], 'POST', remainingParams, None, self.json(body)) def clob_public_get_spread(self, params={}): """ fetches the bid-ask spread for a specific token from CLOB API https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spread :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetSpread() requires a token_id parameter') # Note: REST API endpoint format: /spread?token_id={token_id} # See https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spread remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('spread', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_last_trade_price(self, params={}): """ fetches the last trade price for a specific token from CLOB API https://docs.polymarket.com/api-reference/trades/get-last-trade-price :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetLastTradePrice() requires a token_id parameter') # Note: REST API endpoint format: /last-trade-price?token_id={token_id} # See https://docs.polymarket.com/api-reference/trades/get-last-trade-price remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('last-trade-price', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_last_trades_prices(self, params={}): """ fetches last trade prices for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/trades/get-last-trades-prices :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch last trade prices for(required) :returns dict: response from the exchange """ tokenIds = self.safe_value(params, 'token_ids') if tokenIds is None or not isinstance(tokenIds, list) or len(tokenIds) == 0: raise ArgumentsRequired(self.id + ' clobPublicGetLastTradesPrices() requires a token_ids parameter(array of token IDs)') # Note: REST API endpoint format: POST /last-trades-prices with JSON body # See https://docs.polymarket.com/api-reference/trades/get-last-trades-prices # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: {[token_id]: "price", ...} body: List[Any] = [] for i in range(0, len(tokenIds)): body.append({'token_id': tokenIds[i]}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'token_ids')) return self.request('last-trades-prices', ['clob', 'public'], 'POST', remainingParams, None, self.json(body)) def clob_public_get_trades(self, params={}): """ fetches trades for a specific market from CLOB API https://docs.polymarket.com/developers/CLOB/clients/methods-public#trades :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: the token ID or condition ID(required) :param int [params.limit]: maximum number of trades to return(default: 100, max: 500) :param str [params.side]: filter by side: 'BUY' or 'SELL' :param int [params.start_timestamp]: start timestamp in seconds :param int [params.end_timestamp]: end timestamp in seconds :returns dict: response from the exchange """ market = self.safe_string(params, 'market') if market is None: raise ArgumentsRequired(self.id + ' clobPublicGetTrades() requires a market(token_id or condition_id) parameter') # Note: REST API endpoint format: /trades?market={token_id} # See https://docs.polymarket.com/developers/CLOB/clients/methods-public#trades request: dict = { 'market': market, } limit = self.safe_integer(params, 'limit') if limit is not None: request['limit'] = min(limit, 500) # Cap at 500 side = self.safe_string(params, 'side') if side is not None: request['side'] = side startTimestamp = self.safe_integer(params, 'start_timestamp') if startTimestamp is not None: request['start_timestamp'] = startTimestamp endTimestamp = self.safe_integer(params, 'end_timestamp') if endTimestamp is not None: request['end_timestamp'] = endTimestamp remainingParams = self.extend({'api_type': 'clob'}, self.extend(request, self.omit(params, ['market', 'limit', 'side', 'start_timestamp', 'end_timestamp']))) return self.request('trades', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_tick_size(self, params={}): """ fetches the tick size for a token from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetTickSize() requires a token_id parameter') # Based on get_tick_size() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_TICK_SIZE = "/tick-size") remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('tick-size', ['clob', 'public'], 'GET', remainingParams) def clob_public_get_neg_risk(self, params={}): """ fetches the negative risk flag for a token from CLOB API https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: the token ID(required) :returns dict: response from the exchange """ tokenId = self.safe_string(params, 'token_id') if tokenId is None: raise ArgumentsRequired(self.id + ' clobPublicGetNegRisk() requires a token_id parameter') # Based on get_neg_risk() from py-clob-client # See https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_NEG_RISK = "/neg-risk") remainingParams = self.extend({'api_type': 'clob'}, params) return self.request('neg-risk', ['clob', 'public'], 'GET', remainingParams) def clob_public_post_spreads(self, params={}): """ fetches bid-ask spreads for multiple tokens from CLOB API https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spreads :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.token_ids]: array of token IDs to fetch spreads for(required) :returns dict: response from the exchange """ tokenIds = self.safe_value(params, 'token_ids') if tokenIds is None or not isinstance(tokenIds, list) or len(tokenIds) == 0: raise ArgumentsRequired(self.id + ' clobPublicPostSpreads() requires a token_ids parameter(array of token IDs)') # Note: REST API endpoint format: POST /spreads # See https://docs.polymarket.com/api-reference/spreads/get-bid-ask-spreads # Request body: [{token_id: "..."}, {token_id: "..."}, ...] # Response format: {[token_id]: "spread", ...} body: List[Any] = [] for i in range(0, len(tokenIds)): body.append({'token_id': tokenIds[i]}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'token_ids')) return self.request('spreads', ['clob', 'public'], 'POST', remainingParams, None, self.json(body)) def clob_private_get_order(self, params={}): """ fetches a specific order by order ID https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_ORDER = "/data/order/") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID(required) :returns dict: response from the exchange """ orderId = self.safe_string(params, 'order_id') if orderId is None: raise ArgumentsRequired(self.id + ' clobPrivateGetOrder() requires an order_id parameter') path = 'data/order/' + self.encode_uri_component(orderId) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'order_id')) return self.request(path, ['clob', 'private'], 'GET', remainingParams) def clob_private_get_orders(self, params={}): """ fetches orders for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(ORDERS = "/data/orders") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: filter orders by token ID :param str [params.status]: filter orders by status(OPEN, FILLED, CANCELLED, etc.) :returns dict: response from the exchange """ return self.request('data/orders', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_private_post_order(self, params={}): """ creates a new order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(POST_ORDER = "/order") https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters :param dict [params]: extra parameters specific to the exchange API endpoint :param dict [params.order]: order object(required) :param str [params.owner]: api key of order owner(required) :param str [params.orderType]: order type: "FOK", "GTC", "GTD"(required) :returns dict: response from the exchange """ # Build request payload according to API specification # See https://docs.polymarket.com/developers/CLOB/orders/create-order#request-payload-parameters order = self.safe_value(params, 'order') if order is None: raise ArgumentsRequired(self.id + ' clobPrivatePostOrder() requires an order parameter') owner = self.safe_string(params, 'owner') if owner is None: raise ArgumentsRequired(self.id + ' clobPrivatePostOrder() requires an owner parameter(API key)') orderType = self.safe_string(params, 'orderType') if orderType is None: raise ArgumentsRequired(self.id + ' clobPrivatePostOrder() requires an orderType parameter') # Build the complete request payload with top-level fields requestPayload: dict = { 'order': order, 'owner': owner, 'orderType': orderType, } # Add optional parameters if provided clientOrderId = self.safe_string(params, 'clientOrderId') if clientOrderId is not None: requestPayload['clientOrderId'] = clientOrderId postOnly = self.safe_bool(params, 'postOnly') if postOnly is not None: requestPayload['postOnly'] = postOnly # Send the complete request payload body body = self.json(requestPayload) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, ['order', 'owner', 'orderType', 'clientOrderId', 'postOnly'])) return self.request('order', ['clob', 'private'], 'POST', remainingParams, None, body) def clob_private_post_orders(self, params={}): """ creates multiple orders in a batch https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(POST_ORDERS = "/orders") :param dict [params]: extra parameters specific to the exchange API endpoint :param Array [params.orders]: array of order objects(required) :returns dict: response from the exchange """ orders = self.safe_value(params, 'orders') if orders is None or not isinstance(orders, list): raise ArgumentsRequired(self.id + ' clobPrivatePostOrders() requires an orders parameter(array of order objects)') body = self.json(orders) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'orders')) return self.request('orders', ['clob', 'private'], 'POST', remainingParams, None, body) def clob_private_delete_order(self, params={}): """ cancels an order https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL = "/order") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID to cancel(required) :returns dict: response from the exchange """ orderId = self.safe_string(params, 'order_id') if orderId is None: raise ArgumentsRequired(self.id + ' clobPrivateDeleteOrder() requires an order_id parameter') request: dict = { 'orderID': orderId, } remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'order_id')) body = self.json(request) return self.request('order', ['clob', 'private'], 'DELETE', remainingParams, None, body) def clob_private_delete_orders(self, params={}): """ cancels multiple orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ORDERS = "/orders") :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.order_ids]: array of order IDs to cancel(required) :returns dict: response from the exchange """ orderIds = self.safe_value(params, 'order_ids') if orderIds is None or not isinstance(orderIds, list): raise ArgumentsRequired(self.id + ' clobPrivateDeleteOrders() requires an order_ids parameter(array of order IDs)') body = self.json(orderIds) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, 'order_ids')) return self.request('orders', ['clob', 'private'], 'DELETE', remainingParams, None, body) def clob_private_delete_cancel_all(self, params={}): """ cancels all open orders https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(CANCEL_ALL = "/cancel-all") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: optional token ID to cancel all orders for a specific market :returns dict: response from the exchange """ body = self.json(params) return self.request('cancel-all', ['clob', 'private'], 'DELETE', {'api_type': 'clob'}, None, body) def clob_private_delete_cancel_market_orders(self, params={}): """ cancels all orders from a market https://docs.polymarket.com/developers/CLOB/orders/cancel-market-orders :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.market]: condition id of the market :param str [params.asset_id]: id of the asset/token :returns dict: response from the exchange """ request: dict = {} market = self.safe_string(params, 'market') if market is not None: request['market'] = market assetId = self.safe_string(params, 'asset_id') if assetId is not None: request['asset_id'] = assetId remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, ['market', 'asset_id'])) body = self.json(request) return self.request('cancel-market-orders', ['clob', 'private'], 'DELETE', remainingParams, None, body) def clob_private_get_trades(self, params={}): """ fetches trade history for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py(get_trades method) :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: filter trades by token ID :param int [params.start_timestamp]: start timestamp in seconds :param str [params.next_cursor]: pagination cursor :returns dict: response from the exchange """ # NOTE: the authenticated L2 endpoint is `/trades`(without the public `/data/` prefix). # Using the public path would return all market trades instead of the caller's own fills. return self.request('trades', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_private_get_builder_trades(self, params={}): """ fetches trades originated by the builder https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_BUILDER_TRADES = "/builder-trades") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.token_id]: filter trades by token ID :param int [params.start_timestamp]: start timestamp in seconds :param str [params.next_cursor]: pagination cursor :returns dict: response from the exchange """ return self.request('builder-trades', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_private_get_notifications(self, params={}): """ fetches notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_NOTIFICATIONS = "/notifications") :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ return self.request('notifications', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_private_delete_notifications(self, params={}): """ drops notifications for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(DROP_NOTIFICATIONS = "/notifications") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.notification_id]: specific notification ID to drop :returns dict: response from the exchange """ return self.request('notifications', ['clob', 'private'], 'DELETE', self.extend({'api_type': 'clob'}, params)) def clob_private_get_balance_allowance(self, params={}): """ fetches balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(GET_BALANCE_ALLOWANCE = "/balance-allowance") :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ return self.request('balance-allowance', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_private_put_balance_allowance(self, params={}): """ updates balance and allowance for the authenticated user https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(UPDATE_BALANCE_ALLOWANCE = "/balance-allowance") :param dict [params]: extra parameters specific to the exchange API endpoint :param int [params.signature_type]: signature type(default: from options.signatureType or options.signatureTypes.EOA). :returns dict: response from the exchange """ body = self.json(params) return self.request('balance-allowance', ['clob', 'private'], 'PUT', {'api_type': 'clob'}, None, body) def clob_private_get_is_order_scoring(self, params={}): """ checks if an order is currently scoring https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(IS_ORDER_SCORING = "/is-order-scoring") :param dict [params]: extra parameters specific to the exchange API endpoint :param str [params.order_id]: the order ID(required) :param str [params.token_id]: the token ID(required) :param str [params.side]: the side: 'BUY' or 'SELL'(required) :param str [params.price]: the price(required) :param str [params.size]: the size(required) :returns dict: response from the exchange """ # GET /order-scoring?order_id=... return self.request('order-scoring', ['clob', 'private'], 'GET', self.extend({'api_type': 'clob'}, params)) def clob_private_post_are_orders_scoring(self, params={}): """ checks if multiple orders are currently scoring https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/endpoints.py(ARE_ORDERS_SCORING = "/are-orders-scoring") :param dict [params]: extra parameters specific to the exchange API endpoint :param str[] [params.orderIds]: array of order IDs to check(required) :returns dict: response from the exchange """ orderIds = self.safe_value_2(params, 'orderIds', 'order_ids') if orderIds is None or not isinstance(orderIds, list): raise ArgumentsRequired(self.id + ' clobPrivatePostAreOrdersScoring() requires an orderIds parameter(array of order IDs)') body = self.json({'orderIds': orderIds}) remainingParams = self.extend({'api_type': 'clob'}, self.omit(params, ['orderIds', 'order_ids'])) # POST /orders-scoring with JSON body {orderIds: [...]} return self.request('orders-scoring', ['clob', 'private'], 'POST', remainingParams, None, body) def get_main_wallet_address(self): """ gets main wallet address(walletAddress or options.funder) :returns str: main wallet address """ if self.walletAddress is not None and self.walletAddress != '': return self.walletAddress funder = self.safe_string(self.options, 'funder') if funder is not None and funder != '': return funder raise ArgumentsRequired(self.id + ' getMainWalletAddress() requires a wallet address. Set `walletAddress` or `options.funder`.') def get_proxy_wallet_address(self): """ gets proxy wallet address for Data-API endpoints(falls back to main wallet if not set) :returns str: proxy wallet address """ if self.uid is not None and self.uid != '': return self.uid proxyWallet = self.safe_string(self.options, 'proxyWallet') if proxyWallet is not None and proxyWallet != '': return proxyWallet # Fall back to main wallet if proxyWallet is not set return self.get_main_wallet_address() def get_builder_wallet_address(self): """ gets builder wallet address(falls back to main wallet if not set) :returns str: builder wallet address """ builderWallet = self.safe_string(self.options, 'builderWallet') if builderWallet is not None and builderWallet != '': return builderWallet # Fall back to main wallet if builderWallet is not set return self.get_main_wallet_address() def get_user_total_value(self, userAddress: str = None) -> dict: """ fetches total value of a user's positions from Data-API https://docs.polymarket.com/api-reference/core/get-total-value-of-a-users-positions :param str [userAddress]: user wallet address(defaults to getProxyWalletAddress()) :returns dict: object with 'value'(number) and 'response'(raw API response) """ address: str = None if userAddress is not None: # Use provided address directly(public endpoint, no wallet setup needed) address = userAddress else: # Try to get proxy wallet address, but handle case where wallet is not configured # This allows public calls without requiring wallet setup try: address = self.get_proxy_wallet_address() except Exception as e: # If wallet is not configured, require userAddress parameter for public calls raise ArgumentsRequired(self.id + ' getUserTotalValue() requires a userAddress parameter when wallet is not configured. This is a public endpoint that can query any user address.') # Fetch total value from Data-API valueResponse = self.data_public_get_total_value({'user': address}) # Response format: [{"user": "0x...", "value": 123}] valueData = valueResponse if isinstance(valueResponse, list): if len(valueResponse) > 0: valueData = valueResponse[0] else: valueData = {} totalValue = self.safe_number(valueData, 'value', 0) return { 'value': totalValue, 'response': valueResponse, } def get_user_positions(self, userAddress: str = None, params={}) -> dict: """ fetches current positions for a user from Data-API(defaults to proxy wallet) https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user :param str [userAddress]: user wallet address(defaults to getProxyWalletAddress()) :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ # TODO add pagination, sort, limit etc https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user address: str = None if userAddress is not None: # Use provided address directly(public endpoint, no wallet setup needed) address = userAddress else: # Try to get proxy wallet address, but handle case where wallet is not configured # This allows public calls without requiring wallet setup try: address = self.get_proxy_wallet_address() except Exception as e: # If wallet is not configured, require userAddress parameter for public calls raise ArgumentsRequired(self.id + ' getUserPositions() requires a userAddress parameter when wallet is not configured. This is a public endpoint that can query any user address.') return self.data_public_get_positions(self.extend({'user': address}, params)) def get_user_activity(self, userAddress: str = None, params={}) -> dict: """ fetches user activity from Data-API(defaults to proxy wallet) https://docs.polymarket.com/api-reference/core/get-user-activity :param str [userAddress]: user wallet address(defaults to getProxyWalletAddress()) :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict: response from the exchange """ address: str = None if userAddress is not None: # Use provided address directly(public endpoint, no wallet setup needed) address = userAddress else: # Try to get proxy wallet address, but handle case where wallet is not configured # This allows public calls without requiring wallet setup try: address = self.get_proxy_wallet_address() except Exception as e: # If wallet is not configured, require userAddress parameter for public calls raise ArgumentsRequired(self.id + ' getUserActivity() requires a userAddress parameter when wallet is not configured. This is a public endpoint that can query any user address.') request: dict = { 'user': address, 'limit': self.safe_integer(params, 'limit', 100), 'offset': self.safe_integer(params, 'offset', 0), 'sortBy': self.safe_string(params, 'sortBy', 'TIMESTAMP'), 'sortDirection': self.safe_string(params, 'sortDirection', 'DESC'), } return self.data_public_get_activity(self.extend(request, self.omit(params, ['user']))) def parse_user_activity(self, activity: dict, market: Market = None) -> dict: """ parse a raw user activity record into a trade-like structure consumable by parseTrades :param dict activity: raw activity payload from Data-API :param dict [market]: market structure, when known :returns dict|None: normalized activity(only for TRADE records) or None """ activityType = self.safe_string(activity, 'type') if activityType != 'TRADE': return None rawTs = self.safe_integer(activity, 'timestamp') isoTimestamp = self.safe_string(activity, 'timestamp') if rawTs is not None: tsMs = rawTs * 1000 if (rawTs < 1000000000000) else rawTs isoTimestamp = self.iso8601(tsMs) symbol = market['symbol'] if (market is not None) else self.safe_string(activity, 'condition_id') return self.extend(activity, { 'timestamp': isoTimestamp, 'transactionHash': self.safe_string(activity, 'transactionHash'), 'symbol': symbol, 'asset': self.safe_string(activity, 'asset'), 'price': self.safe_number(activity, 'price'), 'size': self.safe_number(activity, 'size'), 'side': self.safe_string(activity, 'side'), }) def format_address(self, address: str = None): if address is None: return None if address.startswith('0x'): return address.replace('0x', '') return address def normalize_address(self, address: str) -> str: normalized = str(address).strip() if not normalized.startswith('0x'): normalized = '0x' + normalized return normalized.lower() def hash_message(self, message: str) -> str: binaryMessage = self.encode(message) binaryMessageLength = self.binary_length(binaryMessage) x19 = self.base16_to_binary('19') newline = self.base16_to_binary('0a') prefix = self.binary_concat(x19, self.encode('Ethereum Signed Message:'), newline, self.encode(self.number_to_string(binaryMessageLength))) return '0x' + self.hash(self.binary_concat(prefix, binaryMessage), 'keccak', 'hex') def get_contract_config(self, chainID: float) -> dict: contracts = self.safe_value(self.options, 'contracts', {}) chainIdStr = str(chainID) contractConfig = self.safe_value(contracts, chainIdStr) if contractConfig is None: raise ExchangeError(self.id + ' getContractConfig() invalid network chainId: ' + chainIdStr) return contractConfig def sign_message(self, message: str, privateKey: str) -> str: hash = self.hash_message(message) return self.sign_hash(hash, privateKey) def sign_hash(self, hash: str, privateKey: str): signature = self.ecdsa(hash[-64:], privateKey[-64:], 'secp256k1', None) r = signature['r'] s = signature['s'] v = self.int_to_base16(self.sum(27, signature['v'])) # Convert to lowercase hex(Ethereum standard) finalSignature = ('0x' + r.rjust(64, '0') + s.rjust(64, '0') + v.rjust(2, '0')).lower() return finalSignature def sign_typed_data(self, domain: dict, types: dict, value: dict) -> str: # This returns binary data: 0x1901 or hashDomain(domain) or hashStruct(primaryType, types, value) encoded = self.eth_encode_structured_data(domain, types, value) # Hash the encoded binary data with keccak256 hash = '0x' + self.hash(encoded, 'keccak', 'hex') # Sign the hash using signHash signature = self.sign_hash(hash, self.privateKey) return signature def create_level1_headers(self, walletAddress: str, nonce: float = None) -> dict: if walletAddress is None or walletAddress == '': raise ArgumentsRequired(self.id + ' createLevel1Headers() requires a valid walletAddress') normalizedAddress = self.normalize_address(walletAddress) chainId = self.safe_integer(self.options, 'chainId') timestampSeconds = int(math.floor(self.milliseconds()) / 1000) timestamp = str(timestampSeconds) nonceValue = 0 if nonce is not None: nonceValue = nonce clobDomainName = self.safe_string(self.options, 'clobDomainName') clobVersion = self.safe_string(self.options, 'clobVersion') msgToSign = self.safe_string(self.options, 'msgToSign') domain = { 'name': clobDomainName, 'version': clobVersion, 'chainId': chainId, } # https://github.com/Polymarket/clob-client/blob/b75aec68be17190215b7230372fbedfe85de20ef/src/signing/eip712.ts#L28 types = { 'ClobAuth': [ {'name': 'address', 'type': 'address'}, {'name': 'timestamp', 'type': 'string'}, {'name': 'nonce', 'type': 'uint256'}, {'name': 'message', 'type': 'string'}, ], } message = { 'address': normalizedAddress, 'timestamp': timestamp, 'nonce': nonceValue, 'message': msgToSign, } signature = self.sign_typed_data(domain, types, message) headers = { 'POLY_ADDRESS': normalizedAddress, 'POLY_TIMESTAMP': timestamp, 'POLY_NONCE': str(nonceValue), 'POLY_SIGNATURE': signature, } return headers def get_clob_base_url(self, params={}) -> str: """ Gets the CLOB API base URL(handles sandbox mode and custom hosts) :param dict [params]: extra parameters :returns str: base URL for CLOB API """ apiType = self.safe_string(params, 'api_type', 'clob') baseUrl = self.urls['api'][apiType] # Check for sandbox mode if self.isSandboxModeEnabled and self.urls['test'] and self.urls['test'][apiType]: baseUrl = self.urls['test'][apiType] if apiType == 'clob': customHost = self.safe_string(self.options, 'clobHost') if customHost is not None: baseUrl = customHost return baseUrl def parse_api_credentials(self, response: Any) -> dict: """ Parses API credentials from API response and caches them :param dict response: API response :returns dict} API credentials {apiKey, secret, passphrase: """ apiKey = self.safe_string(response, 'apiKey') or self.safe_string(response, 'api_key') secret = self.safe_string(response, 'secret') passphrase = self.safe_string(response, 'passphrase') if not apiKey or not secret or not passphrase: raise ExchangeError(self.id + ' parseApiCredentials() failed to parse credentials. Response: ' + self.json(response)) credentials = { 'apiKey': apiKey, 'secret': secret, 'passphrase': passphrase, } # Cache credentials in options self.options['apiCredentials'] = credentials # Also set them properties for use in sign() method self.apiKey = apiKey self.secret = secret self.password = passphrase return credentials def create_api_key(self, params={}) -> dict: """ Creates a new CLOB API key for the given address https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/authentication :param dict [params]: extra parameters specific to the exchange API endpoint :param number [params.nonce]: optional nonce/timestamp :returns dict} API credentials {apiKey, secret, passphrase: @note Uses manual URL building instead of self.request() because self endpoint requires L1 authentication (POLY_ADDRESS, POLY_SIGNATURE headers) rather than the standard L2 authentication used by self.request() """ if self.privateKey is None: raise ArgumentsRequired(self.id + ' create_api_key() requires a privateKey') # Validate privateKey format(should be hex string with 0x prefix, 66 chars total) if not self.privateKey.startswith('0x') or len(self.privateKey) != 66: raise ArgumentsRequired(self.id + ' create_api_key() requires a valid privateKey(0x-prefixed hex string, 66 characters)') walletAddress = self.get_main_wallet_address() # Validate walletAddress format(should be hex string with 0x prefix, 42 chars total) if not walletAddress.startswith('0x') or len(walletAddress) != 42: raise ArgumentsRequired(self.id + ' create_api_key() requires a valid walletAddress(0x-prefixed hex string, 42 characters). Got: ' + walletAddress) baseUrl = self.get_clob_base_url(params) nonce = self.safe_integer(params, 'nonce') headers = self.create_level1_headers(walletAddress, nonce) url = baseUrl + '/auth/api-key' # POST /auth/api-key(creates new API credentials with L1 authentication) response = self.fetch(url, 'POST', headers, None) return self.parse_api_credentials(response) def derive_api_key(self, params={}) -> dict: """ Derives an already existing CLOB API key for the given address and nonce https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py https://docs.polymarket.com/developers/CLOB/authentication :param dict [params]: extra parameters specific to the exchange API endpoint :param number [params.nonce]: optional nonce/timestamp :returns dict} API credentials {apiKey, secret, passphrase: @note Uses manual URL building instead of self.request() because self endpoint requires L1 authentication (POLY_ADDRESS, POLY_SIGNATURE headers) rather than the standard L2 authentication used by self.request() """ if self.privateKey is None: raise ArgumentsRequired(self.id + ' derive_api_key() requires a privateKey') walletAddress = self.get_main_wallet_address() baseUrl = self.get_clob_base_url(params) nonce = self.safe_integer(params, 'nonce') headers = self.create_level1_headers(walletAddress, nonce) url = baseUrl + '/auth/derive-api-key' # GET /auth/derive-api-key(derives existing API credentials with L1 authentication) response = self.fetch(url, 'GET', headers, None) return self.parse_api_credentials(response) def create_or_derive_api_creds(self, params={}) -> dict: """ Creates API creds if not already created for nonce, otherwise derives them https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict [params]: extra parameters specific to the exchange API endpoint :param number [params.nonce]: optional nonce/timestamp :returns dict} API credentials {apiKey, secret, passphrase: """ # Check if credentials are already cached cachedCreds = self.safe_dict(self.options, 'apiCredentials') if cachedCreds is not None: return cachedCreds # Try create_api_key first, then derive_api_key if create fails # Based on py-clob-client client.py: create_or_derive_api_creds() try: return self.create_api_key(params) except Exception as e: # If create fails(e.g., key already exists), try to derive it return self.derive_api_key(params) def set_api_creds(self, credentials: dict): """ Sets API credentials(alias for caching credentials) https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/client.py :param dict credentials: API credentials {apiKey, secret, passphrase} """ self.options['apiCredentials'] = credentials self.apiKey = self.safe_string(credentials, 'apiKey') self.secret = self.safe_string(credentials, 'secret') self.password = self.safe_string(credentials, 'passphrase') def get_api_base_url(self, params={}) -> str: """ Gets the API base URL for the specified API type(handles sandbox mode and custom hosts) :param dict [params]: extra parameters :param str [params.api_type]: API type('clob', 'gamma', 'data', etc.) :returns str: base URL for the API """ apiType = self.safe_string(params, 'api_type', 'clob') # Ensure urls.api exists if self.urls is None or self.urls['api'] is None: raise ExchangeError(self.id + ' getApiBaseUrl() failed: urls.api is not initialized. Make sure exchange is properly initialized.') # Direct access to nested object property baseUrl = self.urls['api'][apiType] # Check for sandbox mode if self.isSandboxModeEnabled and self.urls['test'] and self.urls['test'][apiType]: baseUrl = self.urls['test'][apiType] # Allow custom CLOB host override if apiType == 'clob': customHost = self.safe_string(self.options, 'clobHost') if customHost is not None: baseUrl = customHost # Ensure we have a valid base URL if baseUrl is None: apiUrls = self.urls['api'] or {} availableTypesList = list(apiUrls.keys()) availableTypes = '' if len(availableTypesList) > 0: availableTypes = ', '.join(availableTypesList) raise ExchangeError(self.id + ' getApiBaseUrl() failed: API type "' + apiType + '" not found in urls.api. Available types: ' + availableTypes) return baseUrl def build_default_headers(self, method: str, existingHeaders: dict = None) -> dict: """ Builds default HTTP headers based on py-clob-client helpers.py https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/http_helpers/helpers.py :param str method: HTTP method('GET', 'POST', etc.) :param dict [existingHeaders]: existing headers to self.extend :returns dict: headers dictionary """ if existingHeaders is None: existingHeaders = {} headers = self.extend({ 'User-Agent': 'ccxt', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Type': 'application/json', }, existingHeaders) # Add Accept-Encoding for GET requests(as per py-clob-client) if method == 'GET': headers['Accept-Encoding'] = 'gzip' return headers def build_public_request(self, baseUrl: str, pathWithParams: str, method: str, queryParams: dict, body: str = None, headers: dict = None) -> dict: """ Builds a public(unauthenticated) request :param str baseUrl: API base URL :param str pathWithParams: path with parameters :param str method: HTTP method :param dict queryParams: query parameters :param str [body]: request body :param dict [headers]: request headers :returns dict: request object with url, method, body, and headers """ headers = self.build_default_headers(method, headers) url = baseUrl + '/' + pathWithParams if method == 'GET': if queryParams: url += '?' + self.urlencode(queryParams) else: # For POST requests, body should already be set by the calling method if body is None and queryParams: body = self.json(queryParams) return {'url': url, 'method': method, 'body': body, 'headers': headers} def ensure_api_credentials(self, params={}) -> dict: """ Ensures API credentials are generated(lazy generation, similar to dYdX's retrieveCredentials) :param dict [params]: extra parameters specific to the exchange API endpoint :returns dict} API credentials {apiKey, secret, passphrase: """ # Check if credentials are already cached cachedCreds = self.safe_dict(self.options, 'apiCredentials') if cachedCreds is not None: return cachedCreds # Check if credentials are provided directly(apiKey, secret, password) # This allows users to provide credentials directly instead of generating from privateKey if self.apiKey and self.secret and self.password: directCreds = { 'apiKey': self.apiKey, 'secret': self.secret, 'passphrase': self.password, } self.set_api_creds(directCreds) return directCreds # If direct credentials not provided, check if privateKey is available for generation if self.privateKey is None: raise ArgumentsRequired(self.id + ' ensureApiCredentials() requires either: (1) apiKey + secret + password provided directly, or (2) privateKey to generate credentials') # Generate credentials lazily(similar to dYdX's retrieveCredentials pattern) # This is called automatically before authenticated requests creds = self.create_or_derive_api_creds(params) self.set_api_creds(creds) return creds def get_api_credentials(self) -> dict: """ Gets API credentials from cache or instance properties :returns dict} API credentials {apiKey, secret, password: """ apiKey = self.apiKey secret = self.secret password = self.password # Check if credentials are already cached cachedCreds = self.safe_dict(self.options, 'apiCredentials') if cachedCreds is not None: apiKey = self.safe_string(cachedCreds, 'apiKey') or apiKey secret = self.safe_string(cachedCreds, 'secret') or secret password = self.safe_string(cachedCreds, 'passphrase') or password # If credentials are not available, check if privateKey is set # Only raise error if privateKey is set(meaning user wants authenticated requests) # This allows public requests to work even when privateKey is set but credentials not yet generated if not apiKey or not secret or not password: if self.privateKey is None: # No privateKey set - self should not happen if called from buildPrivateRequest raise ArgumentsRequired(self.id + ' getApiCredentials() called but no credentials available and no privateKey set. This should only be called for authenticated requests. Provide either: (1) apiKey + secret + password directly, or (2) privateKey to generate credentials.') # privateKey is set but credentials not generated yet - self is expected for lazy generation # Don't raise error here, ensureApiCredentials() handle it raise ArgumentsRequired(self.id + ' API credentials not generated. Credentials are automatically generated on first authenticated request, but privateKey is required. Alternatively, provide apiKey + secret + password directly.') return {'apiKey': apiKey, 'secret': secret, 'password': password} def build_request_path_and_payload(self, pathWithParams: str, method: str, queryParams: dict, body: str = None) -> dict: """ Builds the request path and payload for signature :param str pathWithParams: path with parameters :param str method: HTTP method :param dict queryParams: query parameters :param str [body]: request body :returns dict} {requestPath, url, payload, body: """ # Ensure path doesn't have double slashes(pathWithParams may already start with /) normalizedPath = pathWithParams if pathWithParams.startswith('/') else '/' + pathWithParams requestPath = normalizedPath url = requestPath payload = '' if method == 'GET': if queryParams: queryString = self.urlencode(queryParams) url += '?' + queryString payload = queryString else: # For POST/PUT/DELETE, body is part of the signature # Use deterministic JSON serialization(no spaces, compact) matching py-clob-client # json.dumps(body, separators=(",", ":"), ensure_ascii=False) produces compact JSON if body is None and queryParams: # json.dumpsby default produces compact JSON(no spaces) body = json.dumps(queryParams) # Serialize body deterministically if it's an object if body is not None and isinstance(body, dict): body = json.dumps(body) # Use body(quote replacement happens in createLevel2Signature) payload = str(body) if (body is not None and body != '') else '' return {'requestPath': requestPath, 'url': url, 'payload': payload, 'body': body} def create_level2_signature(self, timestamp: str, method: str, requestPath: str, body: str, secret: str) -> str: """ Creates Level 2 authentication signature(HMAC-SHA256) https://docs.polymarket.com/developers/CLOB/authentication :param str timestamp: timestamp string :param str method: HTTP method :param str requestPath: request path :param str body: request body(serialized JSON string) :param str secret: API secret(base64 encoded, URL-safe) :returns str: URL-safe base64 encoded signature """ # Create signature: HMAC-SHA256(timestamp + method + path + body, secret) # Based on Polymarket CLOB API L2 authentication(matches py-clob-client build_hmac_signature) # Use str(method) to preserve case(don't use toUpperCase()) message = str(timestamp) + str(method) + str(requestPath) # Only add body if it exists and is not empty # NOTE: Replace single quotes with double quotes(matching py-clob-client behavior) # This is necessary to generate the same hmac message and typescript messageWithBody = message if body is not None and body != '': messageWithBody = message + str(body).replace("'", '"') # Generate HMAC and return URL-safe base64 # Convert URL-safe base64 to standard base64(replace - with + and _ with /) secretBinary = self.base64_to_binary(str(secret).replace('-', '+').replace('_', '/')) hmacResult = self.hmac(self.encode(messageWithBody), secretBinary, hashlib.sha256, 'base64') return hmacResult.replace('+', '-').replace('/', '_') def create_level2_headers(self, apiKey: str, timestamp: str, signature: str, password: str) -> dict: """ Creates Level 2 authentication headers https://github.com/Polymarket/py-clob-client/blob/main/py_clob_client/headers/headers.py :param str apiKey: API key :param str timestamp: timestamp string :param str signature: signature string :param str password: API passphrase :returns dict: Level 2 headers dictionary """ authHeaders: dict = { 'POLY_API_KEY': apiKey, 'POLY_TIMESTAMP': timestamp, 'POLY_SIGNATURE': signature, 'POLY_PASSPHRASE': password, # Passphrase is required for L2 authentication 'Content-Type': 'application/json', } # Always include POLY_ADDRESS in Level 2 headers(matches GitHub issue #190 fix) # Get wallet address from funder option, walletAddress property, or derive from privateKey walletAddress = self.safe_string(self.options, 'funder') if walletAddress is None and self.walletAddress is not None: walletAddress = self.walletAddress if walletAddress is None and self.privateKey is not None: # Derive wallet address from private key if not provided walletAddress = self.get_main_wallet_address() if walletAddress is not None: # Normalize and checksum the address(EIP-55) walletAddress = self.normalize_address(walletAddress) authHeaders['POLY_ADDRESS'] = walletAddress # # Add signature type if provided(defaults to EOA from options) # signatureType = self.get_signature_type(params) # eoaSignatureType = self.safe_integer(self.safe_dict(self.options, 'signatureTypes', {}), 'EOA', 0) # if signatureType != eoaSignatureType: # authHeaders['POLY_SIGNATURE_TYPE'] = str(signatureType) # } # # Add chain ID(defaults to 137 for Polygon mainnet, 80001 for testnet) # # chain_id: 137 = Polygon mainnet(default), 80001 = Polygon Mumbai testnet # chainId = self.safe_integer(self.options, 'chainId', 137) # authHeaders['POLY_CHAIN_ID'] = str(chainId) return authHeaders def build_private_request(self, baseUrl: str, pathWithParams: str, method: str, queryParams: dict, body: str = None, headers: dict = None) -> dict: """ Builds a private(authenticated) request with L2 authentication :param str baseUrl: API base URL :param str pathWithParams: path with parameters :param str method: HTTP method :param dict queryParams: query parameters :param str [body]: request body :param dict [headers]: existing headers :returns dict: request object with url, method, body, and headers """ # Ensure privateKey is set if self.privateKey is None: raise ArgumentsRequired(self.id + ' requires privateKey for authenticated requests') # Get API credentials - self will raise if credentials not generated # For lazy generation, ensureApiCredentials() should be called before self creds = self.get_api_credentials() timestamp = str(self.nonce()) # Serialize body deterministically if it's an object(matching py-clob-client) # Use json.dumpswhich produces compact JSON by default(no spaces) # This matches: json.dumps(body, separators=(",", ":"), ensure_ascii=False) serializedBody: str = None if body is not None: if isinstance(body, dict): # Deterministic JSON: compact format(no spaces) serializedBody = json.dumps(body) else: serializedBody = str(body) elif queryParams and (method == 'POST' or method == 'PUT' or method == 'DELETE'): # If body is None but we have queryParams for POST/PUT/DELETE, serialize them serializedBody = json.dumps(queryParams) # Build request path and payload using the serialized body pathAndPayload = self.build_request_path_and_payload(pathWithParams, method, queryParams, serializedBody) requestPath = pathAndPayload['requestPath'] requestUrl = pathAndPayload['url'] # Use the serialized body for the actual request(exact string that will be sent) finalBody = serializedBody is not serializedBody if None else pathAndPayload['body'] privateUrl = baseUrl + requestUrl # Create Level 2 signature: for GET requests, do NOT include query params in signature # For POST/PUT/DELETE, include the serialized body(not query params) # This matches py-clob-client: signature = timestamp + method + requestPath [+ body for non-GET] bodyForSignature = None if (method == 'GET') else serializedBody signature = self.create_level2_signature(timestamp, method, requestPath, bodyForSignature, creds['secret']) # Create Level 2 headers authHeaders = self.create_level2_headers(creds['apiKey'], timestamp, signature, creds['password']) # Merge with existing headers headers = self.build_default_headers(method, headers) headers = self.extend(headers, authHeaders) return {'url': privateUrl, 'method': method, 'body': finalBody, 'headers': headers} def sign(self, path, api: Any = [ 'clob', 'public' ], method='GET', params={}, headers=None, body=None): """ Signs a request for authenticated endpoints https://docs.polymarket.com/developers/CLOB/authentication :param str path: API endpoint path :param str api: API type('public' or 'private') :param str method: HTTP method('GET', 'POST', etc.) :param dict params: Request parameters :param dict headers: Request headers :param str body: Request body :returns dict: Signed request with url, method, body, and headers """ # Get API base URL baseUrl = self.get_api_base_url(params) # Build path with parameters pathWithParams = self.implode_params(path, params) query = self.omit(params, self.extract_params(path)) # Remove api_type from query params's not part of the actual API request queryParams = self.omit(query, ['api_type']) # For public endpoints, no authentication needed # api is always an array like ['gamma', 'public'] or ['clob', 'private'] # The second element is the access level(public/private) accessLevel = self.safe_string(api, 1, 'public') if accessLevel == 'public': return self.build_public_request(baseUrl, pathWithParams, method, queryParams, body, headers) # For private endpoints, use L2 authentication return self.build_private_request(baseUrl, pathWithParams, method, queryParams, body, headers) def handle_errors(self, code: int, reason: str, url: str, method: str, headers: dict, body: str, response: Any, requestHeaders: Any, requestBody: Any): if response is None: return None # Polymarket API errors if code >= 400: # Explicitly check for 401(Unauthorized) and raise AuthenticationError if code == 401: authFeedback = self.id + ' ' + method + ' ' + url + ' 401 ' + reason + ' ' + body raise AuthenticationError(authFeedback) # Try to parse error message from response first(can be JSON or text) # Check error message BEFORE status code to catch specific errors like "Order not found" # that may return 400 status but should raise OrderNotFound instead of BadRequest errorMessage = None errorData = None try: if isinstance(response, str): errorMessage = response elif isinstance(response, dict): errorMessage = self.safe_string(response, 'error') if errorMessage is None: errorMessage = self.safe_string(response, 'message') if errorMessage is None: # If no error/message field, use the whole response data errorData = response except Exception as e: errorMessage = body feedback = self.id + ' ' + (errorMessage or body) if errorMessage is not None: # Try exact match first(e.g., "Order not found" -> OrderNotFound) self.throw_exactly_matched_exception(self.exceptions['exact'], errorMessage, feedback) # Then try broad match self.throw_broadly_matched_exception(self.exceptions['broad'], errorMessage, feedback) # If no match, fall through to status code check # Check HTTP status code(use throwExactlyMatchedException for proper type handling) # This handles cases where no specific error message is found in the response codeAsString = str(code) statusCodeFeedback = self.id + ' ' + method + ' ' + url + ' ' + codeAsString + ' ' + reason + ' ' + body self.throw_exactly_matched_exception(self.exceptions['exact'], codeAsString, statusCodeFeedback) # If we reach here, no exception was thrown, so raise a generic error if errorData is not None: raise ExchangeError(self.id + ' ' + self.json(errorData)) else: raise ExchangeError(feedback) return None ================================================ FILE: Trading/Exchange/polymarket/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["Polymarket"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/polymarket/polymarket_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_trading.exchanges as exchanges import octobot_trading.exchanges.connectors.ccxt.constants as ccxt_constants class PolymarketConnector(exchanges.CCXTConnector): def _client_factory( self, force_unauth, keys_adapter: typing.Callable[[exchanges.ExchangeCredentialsData], exchanges.ExchangeCredentialsData]=None ) -> tuple: return super()._client_factory(force_unauth, keys_adapter=self._keys_adapter) def _keys_adapter(self, creds: exchanges.ExchangeCredentialsData) -> exchanges.ExchangeCredentialsData: # if api key and secret are provided, use them as wallet address and private key creds.wallet_address = creds.api_key creds.uid = creds.password creds.private_key = creds.secret creds.api_key = creds.secret = creds.password = None return creds class Polymarket(exchanges.RestExchange): DESCRIPTION = "" DEFAULT_CONNECTOR_CLASS = PolymarketConnector SUPPORT_FETCHING_CANCELLED_ORDERS = False @classmethod def get_name(cls): return 'polymarket' def get_additional_connector_config(self): return { ccxt_constants.CCXT_OPTIONS: { "fetchMarkets": { "types": ["option"], # only polymarket option markets are supported } } } ================================================ FILE: Trading/Exchange/polymarket/resources/Polymarket.md ================================================ Polymarket is a complete RestExchange adaptation for Polymarket platform. ================================================ FILE: Trading/Exchange/polymarket/script/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from .ccxt import CCXTPolymarketExchange, CCXTAsyncPolymarketExchange, CCXTProPolymarketExchange from .polymarket_exchange import Polymarket ================================================ FILE: Trading/Exchange/polymarket/script/download.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import shutil from pathlib import Path from typing import Dict, List, Tuple, Any CCXT_PATH = '../../../../../ccxt' FILE_MAPPINGS: Dict[str, Dict[str, Any]] = { f'{CCXT_PATH}/python/ccxt/polymarket.py': { 'destination': '../ccxt/polymarket_sync.py', 'patches': [ ('from ccxt.abstract.polymarket import ImplicitAPI', 'from .polymarket_abstract import ImplicitAPI'), ], }, f'{CCXT_PATH}/python/ccxt/async_support/polymarket.py': { 'destination': '../ccxt/polymarket_async.py', 'patches': [ ('from ccxt.abstract.polymarket import ImplicitAPI', 'from .polymarket_abstract import ImplicitAPI'), ], }, f'{CCXT_PATH}/python/ccxt/pro/polymarket.py': { 'destination': '../ccxt/polymarket_pro.py', 'patches': [ ('import ccxt.async_support', 'from .polymarket_async import polymarket'), ('class polymarket(ccxt.async_support.polymarket):', 'class polymarket(polymarket):'), ], }, f'{CCXT_PATH}/python/ccxt/abstract/polymarket.py': { 'destination': '../ccxt/polymarket_abstract.py', 'patches': [], }, } def apply_patches(file_path: str, patches: List[Tuple[str, str]]) -> None: """ Apply patches to the copied file. Args: file_path: Path to the destination file patches: List of (old_string, new_string) tuples to replace """ if not patches: return with open(file_path, 'r', encoding='utf-8') as f: content = f.read() original_content = content # Apply each patch for old_string, new_string in patches: if old_string in content: content = content.replace(old_string, new_string) print(f" Applied patch: {old_string[:50]}... -> {new_string[:50]}...") else: print(f" Warning: Patch pattern not found: {old_string[:50]}...") # Only write if content changed if content != original_content: with open(file_path, 'w', encoding='utf-8') as f: f.write(content) print(f" Patches applied successfully") else: print(f" No changes made (patches may have already been applied)") def copy_files() -> None: """ Copy files from ccxt directory to local ccxt directory with optional patches. """ # Get the directory where this script is located script_dir = Path(__file__).parent.absolute() for source_rel, config in FILE_MAPPINGS.items(): dest_rel = config['destination'] patches = config.get('patches', []) # Resolve paths relative to script directory source_path = (script_dir / source_rel).resolve() dest_path = (script_dir / dest_rel).resolve() # Check if source file exists if not source_path.exists(): print(f"Warning: Source file not found: {source_path}") continue # Ensure destination directory exists dest_path.parent.mkdir(parents=True, exist_ok=True) # Copy the file print(f"Copying {source_path.name} -> {dest_path}") shutil.copy2(source_path, dest_path) # Apply patches if any are specified if patches: print(f"Applying {len(patches)} patch(es) to {dest_path.name}") apply_patches(str(dest_path), patches) else: print(f"No patches needed for {dest_path.name}") if __name__ == '__main__': copy_files() ================================================ FILE: Trading/Exchange/polymarket/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/polymarket_websocket_feed/__init__.py ================================================ from .polymarket_websocket import PolymarketWebsocketConnector ================================================ FILE: Trading/Exchange/polymarket_websocket_feed/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["PolymarketWebsocketConnector"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/polymarket_websocket_feed/polymarket_websocket.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges from octobot_trading.enums import WebsocketFeeds as Feeds import tentacles.Trading.Exchange.polymarket.polymarket_exchange as polymarket_exchange class PolymarketWebsocketConnector(exchanges.CCXTWebsocketConnector): EXCHANGE_FEEDS = { Feeds.TRADES: True, Feeds.KLINE: Feeds.UNSUPPORTED.value, Feeds.TICKER: Feeds.UNSUPPORTED.value, Feeds.CANDLE: Feeds.UNSUPPORTED.value, } @classmethod def get_name(cls): return polymarket_exchange.Polymarket.get_name() ================================================ FILE: Trading/Exchange/polymarket_websocket_feed/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/upbitexchange/__init__.py ================================================ from .upbit_exchange import UpbitExchange ================================================ FILE: Trading/Exchange/upbitexchange/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["UpbitExchange"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/upbitexchange/resources/upbitexchange.md ================================================ UpbitExchange is a basic RestExchange adaptation for WavesExchange exchange. ================================================ FILE: Trading/Exchange/upbitexchange/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/upbitexchange/upbit_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.exchanges as exchanges class UpbitExchange(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True @classmethod def get_name(cls): return 'upbit' ================================================ FILE: Trading/Exchange/wavesexchange/__init__.py ================================================ from .wavesexchange_exchange import WavesExchange ================================================ FILE: Trading/Exchange/wavesexchange/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["WavesExchange"], "tentacles-requirements": [] } ================================================ FILE: Trading/Exchange/wavesexchange/resources/wavesexchange.md ================================================ WavesExchange is a basic RestExchange adaptation for WavesExchange exchange. ================================================ FILE: Trading/Exchange/wavesexchange/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Exchange/wavesexchange/wavesexchange_exchange.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums import octobot_commons.enums as commons_enums class WavesExchange(exchanges.RestExchange): DESCRIPTION = "" FIX_MARKET_STATUS = True DUMP_INCOMPLETE_LAST_CANDLE = True # set True in tentacle when the exchange can return incomplete last candles @classmethod def get_name(cls): return 'wavesexchange' def get_adapter_class(self): return WavesCCXTAdapter async def get_symbol_prices(self, symbol: str, time_frame: commons_enums.TimeFrames, limit: int = None, **kwargs: dict) -> typing.Optional[list]: # without limit is not supported if limit is not None: # account for potentially dumped candle limit += 1 return await super().get_symbol_prices(symbol, time_frame, limit=limit, **kwargs) class WavesCCXTAdapter(exchanges.CCXTAdapter): def fix_ticker(self, raw, **kwargs): fixed = super().fix_ticker(raw, **kwargs) fixed[trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value] = \ fixed.get(trading_enums.ExchangeConstantsTickersColumns.TIMESTAMP.value) or self.connector.client.seconds() return fixed ================================================ FILE: Trading/Mode/arbitrage_trading_mode/__init__.py ================================================ from .arbitrage_trading import ArbitrageTradingMode ================================================ FILE: Trading/Mode/arbitrage_trading_mode/arbitrage_container.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants class ArbitrageContainer: # 0.3 % SIMILARITY_RATIO = decimal.Decimal(str(0.003)) def __init__(self, own_exchange_price: decimal.Decimal, target_price: decimal.Decimal, state): self.own_exchange_price: decimal.Decimal = own_exchange_price self.target_price: decimal.Decimal = target_price self.state = state self.passed_initial_order = False self.initial_before_fee_filled_quantity: decimal.Decimal = None self.initial_limit_order_id = None self.secondary_limit_order_id = None self.secondary_stop_order_id = None def is_similar(self, own_exchange_price: decimal.Decimal, state): # if state and initial price is are the same or own_exchange_price is in current arbitrage window return state is self.state and ( own_exchange_price == self.own_exchange_price or ( ( state is trading_enums.EvaluatorStates.LONG and ( self.own_exchange_price * (trading_constants.ONE - ArbitrageContainer.SIMILARITY_RATIO) < own_exchange_price < self.target_price * (trading_constants.ONE + ArbitrageContainer.SIMILARITY_RATIO) ) ) or ( state is trading_enums.EvaluatorStates.SHORT and ( self.target_price * (trading_constants.ONE - ArbitrageContainer.SIMILARITY_RATIO) < own_exchange_price < self.own_exchange_price * (trading_constants.ONE + ArbitrageContainer.SIMILARITY_RATIO) ) ) ) ) def is_expired(self, other_exchanges_average_price): if self.state is trading_enums.EvaluatorStates.LONG: return other_exchanges_average_price < self.target_price * \ (trading_constants.ONE - ArbitrageContainer.SIMILARITY_RATIO) if self.state is trading_enums.EvaluatorStates.SHORT: return other_exchanges_average_price > self.target_price * \ (trading_constants.ONE + ArbitrageContainer.SIMILARITY_RATIO) def should_be_discarded_after_order_cancel(self, order_id): # should be discarded if initial order is cancelled return self.initial_limit_order_id == order_id def is_watching_this_order(self, order_id): return self.initial_limit_order_id == order_id \ or self.secondary_limit_order_id == order_id \ or self.secondary_stop_order_id == order_id ================================================ FILE: Trading/Mode/arbitrage_trading_mode/arbitrage_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import decimal import async_channel.constants as channel_constants import async_channel.channels as channel_instances import octobot.constants as octobot_constants import octobot_commons.data_util as data_util import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.pretty_printer as pretty_printer import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.personal_data as trading_personal_data import octobot_trading.constants as trading_constants import octobot_trading.modes as trading_modes import octobot_trading.octobot_channel_consumer as octobot_channel_consumer import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import tentacles.Trading.Mode.arbitrage_trading_mode.arbitrage_container as arbitrage_container_import class ArbitrageTradingMode(trading_modes.AbstractTradingMode): def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.merged_symbol = None def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.UI.user_input( "portfolio_percent_per_trade", commons_enums.UserInputTypes.FLOAT, 25, inputs, min_val=0, max_val=100, title="Trade size: percent of your portfolio to include in each arbitrage order.", ) self.UI.user_input( "stop_loss_delta_percent", commons_enums.UserInputTypes.FLOAT, 0.1, inputs, min_val=0, max_val=100, title="Stop loss price: price percent from the price of the initial order to set the stop loss on.", ) exchanges = list(self.config[commons_constants.CONFIG_EXCHANGES].keys()) self.UI.user_input( "exchanges_to_trade_on", commons_enums.UserInputTypes.MULTIPLE_OPTIONS, [exchanges[0]], inputs, options=exchanges, title="Trading exchanges: exchanges on which to perform arbitrage trading: these will be used to create " "arbitrage orders. Leaving this empty will result in arbitrage trading on every exchange, " "which is sub-optimal. Add exchange configurations to add exchanges to this list.", ) self.UI.user_input( "minimal_price_delta_percent", commons_enums.UserInputTypes.FLOAT, 0.25, inputs, min_val=0, max_val=100, title="Cross exchange triggering delta: minimal percent difference to trigger an arbitrage order. Remember " "to set it higher than twice your trading exchanges' fees since two orders will be placed each time.", ) self.UI.user_input( "enable_shorts", commons_enums.UserInputTypes.BOOLEAN, True, inputs, title="Enable shorts: enable arbitrage trades starting with a sell order and ending with a buy order.", ) self.UI.user_input( "enable_longs", commons_enums.UserInputTypes.BOOLEAN, True, inputs, title="Enable longs: enable arbitrage trades starting with a buy order and ending with a sell order.", ) def get_current_state(self) -> (str, float): return super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, \ self.producers[0].final_eval if self.producers[0].final_eval else "N/A" def get_mode_producer_classes(self) -> list: return [ArbitrageModeProducer] def get_mode_consumer_classes(self) -> list: return [ArbitrageModeConsumer] async def create_consumers(self) -> list: consumers = await super().create_consumers() # order consumer order_consumer = await exchanges_channel.get_chan(trading_personal_data.OrdersChannel.get_name(), self.exchange_manager.id).new_consumer( self._order_notification_callback, symbol=self.symbol if self.symbol else channel_constants.CHANNEL_WILDCARD ) return consumers + [order_consumer] async def _order_notification_callback(self, exchange, exchange_id, cryptocurrency, symbol, order, update_type, is_from_bot): if order[ trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == trading_enums.OrderStatus.FILLED.value \ and is_from_bot: await self.producers[0].order_filled_callback(order) elif order[ trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == trading_enums.OrderStatus.CANCELED.value \ and is_from_bot: await self.producers[0].order_cancelled_callback(order) @classmethod def get_is_trading_on_exchange(cls, exchange_name, tentacles_setup_config) -> bool: """ :return: True if exchange_name is in exchanges_to_trade_on (case insensitive) or if exchanges_to_trade_on is missing or empty """ exchanges_to_trade_on = tentacles_manager_api.get_tentacle_config(tentacles_setup_config, cls) \ .get("exchanges_to_trade_on", []) return not exchanges_to_trade_on or exchange_name.lower() in [ exchange.lower() for exchange in exchanges_to_trade_on ] @classmethod def get_is_symbol_wildcard(cls) -> bool: """ :return: True if the mode is not symbol dependant else False """ return False @staticmethod def is_backtestable(): return False class ArbitrageModeConsumer(trading_modes.AbstractTradingModeConsumer): ARBITRAGE_CONTAINER_KEY = "arbitrage" ARBITRAGE_PHASE_KEY = "phase" QUANTITY_KEY = "quantity" INITIAL_PHASE = "initial" SECONDARY_PHASE = "secondary" def __init__(self, trading_mode): super().__init__(trading_mode) self.open_arbitrages = [] def on_reload_config(self): """ Called at constructor and after the associated trading mode's reload_config. Implement if necessary """ self.PORTFOLIO_PERCENT_PER_TRADE = decimal.Decimal(str( self.trading_mode.trading_config["portfolio_percent_per_trade"] / 100)) self.STOP_LOSS_DELTA_FROM_OWN_PRICE = decimal.Decimal(str( self.trading_mode.trading_config["stop_loss_delta_percent"] / 100)) async def create_new_orders(self, symbol, final_note, state, **kwargs): # no possible default values in kwargs: interrupt if missing element data = kwargs[self.CREATE_ORDER_DATA_PARAM] phase = data[ArbitrageModeConsumer.ARBITRAGE_PHASE_KEY] arbitrage_container = data[ArbitrageModeConsumer.ARBITRAGE_CONTAINER_KEY] if phase == ArbitrageModeConsumer.INITIAL_PHASE: await self._create_initial_arbitrage_order(arbitrage_container) elif phase == ArbitrageModeConsumer.SECONDARY_PHASE: await self._create_secondary_arbitrage_order(arbitrage_container, data[ArbitrageModeConsumer.QUANTITY_KEY]) async def _create_initial_arbitrage_order(self, arbitrage_container): current_symbol_holding, current_market_holding, market_quantity, price, symbol_market = \ await trading_personal_data.get_pre_order_data(self.exchange_manager, symbol=self.trading_mode.symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT) created_orders = [] order_type = trading_enums.TraderOrderType.BUY_LIMIT \ if arbitrage_container.state is trading_enums.EvaluatorStates.LONG \ else trading_enums.TraderOrderType.SELL_LIMIT quantity = self._get_quantity_from_holdings(current_symbol_holding, market_quantity, arbitrage_container.state) if order_type is trading_enums.TraderOrderType.SELL_LIMIT: quantity = trading_personal_data.decimal_add_dusts_to_quantity_if_necessary(quantity, price, symbol_market, current_symbol_holding) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, arbitrage_container.own_exchange_price, symbol_market): current_order = trading_personal_data.create_order_instance(trader=self.exchange_manager.trader, order_type=order_type, symbol=self.trading_mode.symbol, current_price=arbitrage_container.own_exchange_price, quantity=order_quantity, price=order_price) created_order = await self.trading_mode.create_order(current_order) if created_order is not None: created_orders.append(created_order) arbitrage_container.initial_limit_order_id = created_order.order_id self.open_arbitrages.append(arbitrage_container) # only create one order per arbitrage return created_orders async def _create_secondary_arbitrage_order(self, arbitrage_container, quantity): created_orders = [] current_symbol_holding, current_market_holding, market_quantity, price, symbol_market = \ await trading_personal_data.get_pre_order_data(self.exchange_manager, symbol=self.trading_mode.symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT) now_selling = arbitrage_container.state is trading_enums.EvaluatorStates.LONG entry_id = arbitrage_container.initial_limit_order_id if now_selling: quantity = trading_personal_data.decimal_add_dusts_to_quantity_if_necessary(quantity, price, symbol_market, current_symbol_holding) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, arbitrage_container.target_price, symbol_market ): oco_group = self.exchange_manager.exchange_personal_data.orders_manager.create_group( trading_personal_data.OneCancelsTheOtherOrderGroup, active_order_swap_strategy=trading_personal_data.StopFirstActiveOrderSwapStrategy() ) current_limit_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=trading_enums.TraderOrderType.SELL_LIMIT if now_selling else trading_enums.TraderOrderType.BUY_LIMIT, symbol=self.trading_mode.symbol, current_price=arbitrage_container.own_exchange_price, quantity=order_quantity, price=order_price, group=oco_group, associated_entry_id=entry_id ) stop_price = self._get_stop_loss_price(symbol_market, arbitrage_container.own_exchange_price, now_selling) current_stop_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=trading_enums.TraderOrderType.STOP_LOSS, symbol=self.trading_mode.symbol, current_price=arbitrage_container.own_exchange_price, quantity=order_quantity, price=stop_price, group=oco_group, side=trading_enums.TradeOrderSide.SELL if now_selling else trading_enums.TradeOrderSide.BUY, associated_entry_id=entry_id, ) # in futures, inactive orders are not necessary if self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future: await oco_group.active_order_swap_strategy.apply_inactive_orders( [current_limit_order, current_stop_order] ) if created_limit_order := await self.trading_mode.create_order(current_limit_order): created_stop_order = await self.trading_mode.create_order(current_stop_order) created_orders.append(created_limit_order) arbitrage_container.secondary_limit_order_id = created_limit_order.order_id arbitrage_container.secondary_stop_order_id = created_stop_order.order_id return created_orders return [] def _get_quantity_from_holdings(self, current_symbol_holding, market_quantity, state): # TODO handle quantity in a non dynamic manner (avoid subsequent orders volume reduction) if state is trading_enums.EvaluatorStates.LONG: return market_quantity * self.PORTFOLIO_PERCENT_PER_TRADE return current_symbol_holding * self.PORTFOLIO_PERCENT_PER_TRADE def _get_stop_loss_price(self, symbol_market, starting_price, now_selling): if now_selling: return trading_personal_data.decimal_adapt_price(symbol_market, starting_price * (trading_constants.ONE - self.STOP_LOSS_DELTA_FROM_OWN_PRICE)) return trading_personal_data.decimal_adapt_price(symbol_market, starting_price * (trading_constants.ONE + self.STOP_LOSS_DELTA_FROM_OWN_PRICE)) class ArbitrageModeProducer(trading_modes.AbstractTradingModeProducer): def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) self.own_exchange_mark_price: decimal.Decimal = None self.other_exchanges_mark_prices = {} self.state = trading_enums.EvaluatorStates.NEUTRAL self.final_eval = "" self.quote, self.base = symbol_util.parse_symbol(self.trading_mode.symbol).base_and_quote() self.lock = asyncio.Lock() self.enable_shorts = self.enable_longs = True def on_reload_config(self): """ Called at constructor and after the associated trading mode's reload_config. Implement if necessary """ self.sup_triggering_price_delta_ratio: decimal.Decimal = \ 1 + decimal.Decimal(str(self.trading_mode.trading_config["minimal_price_delta_percent"] / 100)) self.inf_triggering_price_delta_ratio: decimal.Decimal = \ 1 - decimal.Decimal(str(self.trading_mode.trading_config["minimal_price_delta_percent"] / 100)) self.enable_shorts = self.trading_mode.trading_config.get("enable_shorts", True) self.enable_longs = self.trading_mode.trading_config.get("enable_longs", True) async def inner_start(self) -> None: """ Start trading mode channels subscriptions """ try: self.logger.info(f"Starting on listening for {self.trading_mode.symbol} arbitrage opportunities on " f"{self.exchange_name} based on other exchanges prices.") for exchange_id in trading_api.get_all_exchange_ids_with_same_matrix_id(self.exchange_manager.exchange_name, self.exchange_manager.id): # subscribe on existing exchanges if exchange_id != self.exchange_manager.id: await self._subscribe_exchange_id_mark_price(exchange_id) await exchanges_channel.get_chan(trading_constants.MARK_PRICE_CHANNEL, self.exchange_manager.id). \ new_consumer( self._own_exchange_mark_price_callback, symbol=self.trading_mode.symbol ) await channel_instances.get_chan_at_id(octobot_constants.OCTOBOT_CHANNEL, self.trading_mode.bot_id). \ new_consumer( # listen for new available exchange self._exchange_added_callback, subject=commons_enums.OctoBotChannelSubjects.NOTIFICATION.value, action=octobot_channel_consumer.OctoBotChannelTradingActions.EXCHANGE.value ) except Exception as e: self.logger.exception(e, True, f"Error when starting arbitrage trading on {self.exchange_name}: {e}") async def order_filled_callback(self, filled_order): """ Called when an order is filled: create secondary orders if the filled order is an initial order :param filled_order: :return: None """ order_id = filled_order[trading_enums.ExchangeConstantsOrderColumns.ID.value] async with self.lock: arbitrage = self._get_arbitrage(order_id) if arbitrage is not None: filled_quantity = filled_order[trading_enums.ExchangeConstantsOrderColumns.FILLED.value] if arbitrage.passed_initial_order: # filled limit order or stop loss: close arbitrage arbitrage_success = filled_order[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] != \ trading_enums.TradeOrderType.STOP_LOSS.value if arbitrage.state is trading_enums.EvaluatorStates.LONG: filled_quantity = decimal.Decimal(str(filled_quantity * filled_order[ trading_enums.ExchangeConstantsOrderColumns.PRICE.value])) self._log_results(arbitrage, arbitrage_success, filled_quantity) self._close_arbitrage(arbitrage) else: await self._trigger_arbitrage_secondary_order(arbitrage, filled_order, filled_quantity) async def order_cancelled_callback(self, cancelled_order): """ Called when an order is cancelled (from bot or user) :param cancelled_order: the cancelled order :return: None """ order_id = cancelled_order[trading_enums.ExchangeConstantsOrderColumns.ID.value] async with self.lock: to_remove_orders = [arbitrage for arbitrage in self._get_open_arbitrages() if arbitrage.should_be_discarded_after_order_cancel(order_id)] for arbitrage in to_remove_orders: self._close_arbitrage(arbitrage) async def _own_exchange_mark_price_callback( self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, mark_price ): """ Called on a price update from the current exchange :param exchange: name of the exchange :param exchange_id: id of the exchange :param cryptocurrency: related cryptocurrency :param symbol: related symbol :param mark_price: updated mark price :return: None """ self.own_exchange_mark_price = decimal.Decimal(str(mark_price)) try: if self.other_exchanges_mark_prices: await self._analyse_arbitrage_opportunities() except Exception as e: self.logger.exception(e, True, f"Error when handling mark_price_callback for {self.exchange_name}: {e}") async def _mark_price_callback( self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, mark_price ): """ Called on a price update from an exchange that is different from the current one :param exchange: name of the exchange :param exchange_id: id of the exchange :param cryptocurrency: related cryptocurrency :param symbol: related symbol :param mark_price: updated mark price :return: None """ self.other_exchanges_mark_prices[exchange] = decimal.Decimal(str(mark_price)) try: if self.own_exchange_mark_price is not None: await self._analyse_arbitrage_opportunities() except Exception as e: self.logger.exception(e, True, f"Error when handling mark_price_callback for {self.exchange_name}: {e}") async def _analyse_arbitrage_opportunities(self): async with self.trading_mode_trigger(): other_exchanges_average_price = \ decimal.Decimal(str(data_util.mean(self.other_exchanges_mark_prices.values()))) state = None if other_exchanges_average_price > self.own_exchange_mark_price * self.sup_triggering_price_delta_ratio: # min long = high price > own_price / (1 - 2fees) state = trading_enums.EvaluatorStates.LONG elif other_exchanges_average_price < self.own_exchange_mark_price * self.inf_triggering_price_delta_ratio: # min short = low price < own_price * (1 - 2fees) state = trading_enums.EvaluatorStates.SHORT if self._is_traded_state(state): # lock to prevent concurrent order management async with self.lock: # 1. cancel invalided opportunities if any await self._ensure_no_expired_opportunities(other_exchanges_average_price, state) # 2. handle new opportunities await self._trigger_arbitrage_opportunity(other_exchanges_average_price, state) def _is_traded_state(self, state): if state is None: return False if state is trading_enums.EvaluatorStates.SHORT: return self.enable_shorts if state is trading_enums.EvaluatorStates.LONG: return self.enable_longs async def _trigger_arbitrage_opportunity(self, other_exchanges_average_price, state): # ensure no similar arbitrage is already in place if self._ensure_no_existing_arbitrage_on_this_price(state): self._log_arbitrage_opportunity_details(other_exchanges_average_price, state) arbitrage_container = arbitrage_container_import.ArbitrageContainer(self.own_exchange_mark_price, other_exchanges_average_price, state) await self._create_arbitrage_initial_order(arbitrage_container) self._register_state(state, other_exchanges_average_price - self.own_exchange_mark_price) async def _create_arbitrage_initial_order(self, arbitrage_container): if self.exchange_manager.trader.is_enabled: data = { ArbitrageModeConsumer.ARBITRAGE_CONTAINER_KEY: arbitrage_container, ArbitrageModeConsumer.ARBITRAGE_PHASE_KEY: ArbitrageModeConsumer.INITIAL_PHASE } await self.submit_trading_evaluation(cryptocurrency=self.trading_mode.cryptocurrency, symbol=self.trading_mode.symbol, time_frame=None, state=arbitrage_container.state, data=data) async def _trigger_arbitrage_secondary_order(self, arbitrage: arbitrage_container_import.ArbitrageContainer, filled_order: dict, filled_quantity_before_fees: decimal.Decimal): arbitrage.passed_initial_order = True now_buying = arbitrage.state is trading_enums.EvaluatorStates.SHORT # a SHORT arbitrage is an initial SELL followed by a BUY order. # Here in the secondary order construction: # - Buy (at a lower price) when the arbitrage is a SHORT # - Sell (at a higher price) when the arbitrage is a LONG paid_fees_in_quote = trading_personal_data.total_fees_from_order_dict(filled_order, self.quote) secondary_quantity = filled_quantity_before_fees - paid_fees_in_quote filled_price = decimal.Decimal(str(filled_order[trading_enums.ExchangeConstantsOrderColumns.PRICE.value])) if now_buying: arbitrage.initial_before_fee_filled_quantity = filled_quantity_before_fees else: arbitrage.initial_before_fee_filled_quantity = filled_quantity_before_fees * filled_price if now_buying: # buying at a lower price: buy more than what has been sold, take fees into account fees_in_base = trading_personal_data.total_fees_from_order_dict(filled_order, self.base) secondary_base_amount = filled_price * secondary_quantity - fees_in_base secondary_quantity = secondary_base_amount / arbitrage.target_price await self._create_arbitrage_secondary_order(arbitrage, secondary_quantity) async def _create_arbitrage_secondary_order(self, arbitrage_container, secondary_quantity): if self.exchange_manager.trader.is_enabled: data = { ArbitrageModeConsumer.ARBITRAGE_CONTAINER_KEY: arbitrage_container, ArbitrageModeConsumer.ARBITRAGE_PHASE_KEY: ArbitrageModeConsumer.SECONDARY_PHASE, ArbitrageModeConsumer.QUANTITY_KEY: secondary_quantity } await self.submit_trading_evaluation(cryptocurrency=self.trading_mode.cryptocurrency, symbol=self.trading_mode.symbol, time_frame=None, state=arbitrage_container.state, data=data) def _ensure_no_existing_arbitrage_on_this_price(self, state): for arbitrage_container in self._get_open_arbitrages(): if arbitrage_container.is_similar(self.own_exchange_mark_price, state): return False return True def _get_arbitrage(self, order_id): for arbitrage_container in self._get_open_arbitrages(): if arbitrage_container.is_watching_this_order(order_id): return arbitrage_container return None async def _ensure_no_expired_opportunities(self, other_exchanges_average_price, state): to_remove_arbitrages = [] for arbitrage_container in self._get_open_arbitrages(): # look for expired opposite side arbitrages and cancel them if still possible if arbitrage_container.state is not state and \ arbitrage_container.is_expired(other_exchanges_average_price): if self.exchange_manager.trader.is_enabled: if await self._cancel_order(arbitrage_container): to_remove_arbitrages.append(arbitrage_container) for arbitrage in to_remove_arbitrages: self._get_open_arbitrages().remove(arbitrage) async def _cancel_order(self, arbitrage_container) -> bool: try: if await self.trading_mode.cancel_order( self.exchange_manager.exchange_personal_data.orders_manager.get_order( arbitrage_container.initial_limit_order_id ) ): self.logger.info(f"Arbitrage opportunity expired: cancelled initial order on " f"{self.exchange_manager.exchange_name} for {self.trading_mode.symbol} at" f"{arbitrage_container.own_exchange_price}") return True return False except (trading_errors.OrderCancelError, trading_errors.UnexpectedExchangeSideOrderStateError) as err: self.logger.warning(f"Skipping order cancel: {err}") # order can't be cancelled, ignore it and proceed return True except KeyError: # order is not open anymore: can't cancel return False def _log_arbitrage_opportunity_details(self, other_exchanges_average_price, state): price_difference = other_exchanges_average_price / self.own_exchange_mark_price difference_percent = pretty_printer.round_with_decimal_count(float(price_difference) * 100 - 100, 5) self.logger.debug(f"Arbitrage opportunity on {self.exchange_manager.exchange_name} {state.name} for " f"{self.trading_mode.symbol} " f"({str(self.own_exchange_mark_price)} vs {other_exchanges_average_price} on average " f"based on {len(self.other_exchanges_mark_prices)} registered exchange(s): " f"{'+' if price_difference > 1 else ''}{difference_percent}%).") def _log_results(self, arbitrage, success, filled_quantity): self.logger.info(f"Closed {arbitrage.state.name} arbitrage on {self.exchange_manager.exchange_name} [" f"{'success' if success else 'stop loss triggered'}] with {self.trading_mode.symbol}: " f"profit before {'final' if arbitrage.state is trading_enums.EvaluatorStates.SHORT else 'all'} " f"fees: {str(filled_quantity - arbitrage.initial_before_fee_filled_quantity)} " f"{self.quote if arbitrage.state is trading_enums.EvaluatorStates.SHORT else self.base}") def _close_arbitrage(self, arbitrage): self._get_open_arbitrages().remove(arbitrage) self.state = trading_enums.EvaluatorStates.NEUTRAL self.final_eval = "" def _get_open_arbitrages(self): return self.trading_mode.get_trading_mode_consumers()[0].open_arbitrages def _register_state(self, new_state, price_difference): self.state = new_state self.final_eval = f"{'+' if float(price_difference) > 0 else ''}{str(price_difference)}" self.logger.info(f"New state on {self.exchange_manager.exchange_name} for {self.trading_mode.symbol}: " f"{new_state}, price difference: {self.final_eval}") async def _exchange_added_callback(self, bot_id: str, subject: str, action: str, data: dict): if octobot_channel_consumer.OctoBotChannelTradingDataKeys.EXCHANGE_ID.value in data: # New exchange available: subscribe to its price updates await self._subscribe_exchange_id_mark_price( data[octobot_channel_consumer.OctoBotChannelTradingDataKeys.EXCHANGE_ID.value]) async def _subscribe_exchange_id_mark_price(self, exchange_id): await exchanges_channel.get_chan(trading_constants.MARK_PRICE_CHANNEL, exchange_id).new_consumer( self._mark_price_callback, symbol=self.trading_mode.symbol ) registered_exchange_name = trading_api.get_exchange_name( trading_api.get_exchange_manager_from_exchange_id(exchange_id) ) self.logger.info( f"Arbitrage trading for {self.trading_mode.symbol} on {self.exchange_name}: registered " f"{registered_exchange_name} exchange as price data feed reference to identify arbitrage opportunities." ) async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): # Ignore matrix calls pass @classmethod def get_should_cancel_loaded_orders(cls) -> bool: return False async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() await super().stop() ================================================ FILE: Trading/Mode/arbitrage_trading_mode/config/ArbitrageTradingMode.json ================================================ { "minimal_price_delta_percent": 0.25, "portfolio_percent_per_trade": 25, "stop_loss_delta_percent": 0.1, "exchanges_to_trade_on": [], "required_strategies": [] } ================================================ FILE: Trading/Mode/arbitrage_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["ArbitrageTradingMode"], "tentacles-requirements": [] } ================================================ FILE: Trading/Mode/arbitrage_trading_mode/resources/ArbitrageTradingMode.md ================================================ ArbitrageTradingMode is watching prices of the configured trading pairs across the available exchanges to find [arbitrage](https://www.investopedia.com/terms/a/arbitrage.asp) opportunities. ArbitrageTradingMode is watching the price of the traded pairs accord every exchange and computes its average price. If the price of a pair is far enough from its average cross-exchange price, an arbitrage trade is initiated. An arbitrage trade consists in **2 orders**: 1. A limit buy or sell at the current local exchange price 2. When this first order is filled: - A limit buy or a sell at the average price (average of prices on other exchanges) is created to benefit from the arbitrage opportunity - A stop loss on the opposite side is created to secure funds The first limit order is cancelled if the local exchange price reaches the other exchanges average price. **No funds are transferred** from one exchange to another, it all happens on the same exchange. It is recommended to enable arbitrage trading on **few exchanges only** to benefit from **price lag**: simply register these exchanges in your ArbitrageTradingMode configuration. **Every exchange** in your OctoBot configuration will be used to compute the **average price** for each traded pair, therefore you can add **highly liquid exchanges** to be used as **price references only** and quickly spot arbitrage opportunities. By default **every exchange** in your OctoBot configuration is used for arbitrage trading. It is recommended to **narrow this list down** in your ArbitrageTradingMode configuration and **only trade on the ones offering arbitrage opportunities and use the others as price indicators**. Exchanges that are used for **price reference only require no api keys** as no trade is performed on these exchanges.
_This trading mode supports PNL history._ ================================================ FILE: Trading/Mode/arbitrage_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import contextlib import os.path import decimal import mock import octobot_backtesting.api as backtesting_api import async_channel.util as channel_util import octobot_commons.tests.test_config as test_config import octobot_commons.constants as commons_constants import octobot_commons.asyncio_tools as asyncio_tools import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchanges as exchanges import tentacles.Trading.Mode as modes import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges import tests.test_utils.trading_modes as test_trading_modes from tentacles.Trading.Mode.arbitrage_trading_mode.arbitrage_trading import ArbitrageModeProducer @contextlib.asynccontextmanager async def exchange(exchange_name, backtesting=None, symbol="BTC/USDT"): exchange_manager = None try: config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, exchange_name) exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = backtesting or await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = modes.ArbitrageTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol # avoid error with producer init and exchanges keys with mock.patch.object(ArbitrageModeProducer, "start", new=mock.AsyncMock()) as start_mock: await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) # force triggering_price_delta_ratio equivalent to a 0.2% setting in minimal_price_delta_percent delta_percent = 2 test_trading_modes.set_ready_to_start(mode.producers[0]) mode.producers[0].inf_triggering_price_delta_ratio = decimal.Decimal(str(1 - delta_percent / 100)) mode.producers[0].sup_triggering_price_delta_ratio = decimal.Decimal(str(1 + delta_percent / 100)) # let trading modes start await asyncio_tools.wait_asyncio_next_cycle() start_mock.assert_called_once() yield mode.producers[0], mode.get_trading_mode_consumers()[0], exchange_manager finally: if exchange_manager is not None: for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) if exchange_manager.exchange.backtesting.time_updater is not None: await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() ================================================ FILE: Trading/Mode/arbitrage_trading_mode/tests/test_arbitrage_container.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import octobot_trading.enums as trading_enums import tentacles.Trading.Mode.arbitrage_trading_mode.arbitrage_container as arbitrage_container_import def test_is_similar_with_prices_close_to_own_price(): container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(90), decimal.Decimal(100), trading_enums.EvaluatorStates.LONG) # same price and state assert container.is_similar(decimal.Decimal(90), trading_enums.EvaluatorStates.LONG) # same price but different state assert not container.is_similar(decimal.Decimal(90), trading_enums.EvaluatorStates.SHORT) # too different prices comparing to own_exchange_price for price in (decimal.Decimal(110), decimal.Decimal(200), decimal.Decimal(80), decimal.Decimal(20), decimal.Decimal(0)): assert not container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert not container.is_similar(price, trading_enums.EvaluatorStates.LONG) # similar prices comparing to own_exchange_price for price in (decimal.Decimal(str(89.97)), decimal.Decimal(str(90.01))): assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) def test_is_similar_with_prices_close_to_own_price_very_low_prices(): container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(0.00000621)), decimal.Decimal(str(0.00000645)), trading_enums.EvaluatorStates.LONG) # too different prices comparing to own_exchange_price for price in (decimal.Decimal(str(0.0000060)), decimal.Decimal(str(0.0000061)), decimal.Decimal(str(0.0000065)), decimal.Decimal(str(0.000007))): assert not container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert not container.is_similar(price, trading_enums.EvaluatorStates.LONG) # similar prices comparing to own_exchange_price for price in (decimal.Decimal(str(0.000006196)), decimal.Decimal(str(0.00000620)), decimal.Decimal(str(0.00000646)), decimal.Decimal(str(0.000006463))): assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(0.00000062)), decimal.Decimal(str(0.00000064)), trading_enums.EvaluatorStates.LONG) # too different prices comparing to own_exchange_price for price in (decimal.Decimal(str(0.00000060)), decimal.Decimal(str(0.00000061)), decimal.Decimal(str(0.00000065)), decimal.Decimal(str(0.0000007))): assert not container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert not container.is_similar(price, trading_enums.EvaluatorStates.LONG) # similar prices comparing to own_exchange_price for price in (decimal.Decimal(str(0.0000006199)), decimal.Decimal(str(0.0000006401))): assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) def test_is_similar_with_prices_in_arbitrage_range(): container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(90), decimal.Decimal(100), trading_enums.EvaluatorStates.LONG) for price in range(int(container.own_exchange_price), int(container.target_price)): assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) assert container.is_similar(price, trading_enums.EvaluatorStates.LONG) container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(100), decimal.Decimal(90), trading_enums.EvaluatorStates.SHORT) for price in range(int(container.target_price), int(container.own_exchange_price)): assert container.is_similar(price, trading_enums.EvaluatorStates.SHORT) assert container.is_similar(price, trading_enums.EvaluatorStates.SHORT) def test_is_expired(): container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(90), decimal.Decimal(100), trading_enums.EvaluatorStates.LONG) assert not container.is_expired(decimal.Decimal(99.99)) assert container.is_expired(decimal.Decimal(99)) container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(100), decimal.Decimal(90), trading_enums.EvaluatorStates.SHORT) assert not container.is_expired(decimal.Decimal(90.01)) assert container.is_expired(decimal.Decimal(91)) def test_is_expired_very_low_prices(): container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(0.00000621)), decimal.Decimal(str(0.00000645)), trading_enums.EvaluatorStates.LONG) assert not container.is_expired(decimal.Decimal(str(0.00000644))) assert container.is_expired(decimal.Decimal(str(0.00000643))) container = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(0.00000062)), decimal.Decimal(str(0.00000064)), trading_enums.EvaluatorStates.LONG) assert not container.is_expired(decimal.Decimal(str(0.000000639))) assert container.is_expired(decimal.Decimal(str(0.000000637))) def test_should_be_discarded_after_order_cancel(): container = arbitrage_container_import.ArbitrageContainer(90, 100, trading_enums.EvaluatorStates.LONG) assert not container.should_be_discarded_after_order_cancel("123") container.initial_limit_order_id = "123" assert container.should_be_discarded_after_order_cancel("123") assert not container.should_be_discarded_after_order_cancel("1234") def test_is_watching_this_order(): container = arbitrage_container_import.ArbitrageContainer(90, 100, trading_enums.EvaluatorStates.LONG) assert not container.is_watching_this_order("init") assert not container.is_watching_this_order("sec") assert not container.is_watching_this_order("stop") container.initial_limit_order_id = "init" assert container.is_watching_this_order("init") assert not container.is_watching_this_order("sec") assert not container.is_watching_this_order("stop") container.secondary_limit_order_id = "sec" assert container.is_watching_this_order("init") assert container.is_watching_this_order("sec") assert not container.is_watching_this_order("stop") container.secondary_stop_order_id = "stop" assert container.is_watching_this_order("init") assert container.is_watching_this_order("sec") assert container.is_watching_this_order("stop") container.initial_limit_order_id = None assert not container.is_watching_this_order("init") assert container.is_watching_this_order("sec") assert container.is_watching_this_order("stop") container.secondary_limit_order_id = None assert not container.is_watching_this_order("init") assert not container.is_watching_this_order("sec") assert container.is_watching_this_order("stop") container.secondary_stop_order_id = None assert not container.is_watching_this_order("init") assert not container.is_watching_this_order("sec") assert not container.is_watching_this_order("stop") ================================================ FILE: Trading/Mode/arbitrage_trading_mode/tests/test_arbitrage_trading_mode_consumer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import decimal import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import tentacles.Trading.Mode.arbitrage_trading_mode.arbitrage_container as arbitrage_container_import import tentacles.Trading.Mode.arbitrage_trading_mode.arbitrage_trading as arbitrage_trading_mode import tentacles.Trading.Mode.arbitrage_trading_mode.tests as arbitrage_trading_mode_tests import octobot_tentacles_manager.api as tentacles_manager_api # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_init(): tentacles_manager_api.reload_tentacle_info() async with arbitrage_trading_mode_tests.exchange("binance") as arbitrage_trading_mode_tests.exchange_tuple: binance_producer, binance_consumer, _ = arbitrage_trading_mode_tests.exchange_tuple # trading mode assert len(binance_producer.trading_mode.consumers) == 2 assert len(binance_producer.trading_mode.producers) == 1 # consumer assert binance_consumer.PORTFOLIO_PERCENT_PER_TRADE > trading_constants.ZERO assert binance_consumer.STOP_LOSS_DELTA_FROM_OWN_PRICE > trading_constants.ZERO assert binance_consumer.open_arbitrages == [] async def test_create_new_orders(): async with arbitrage_trading_mode_tests.exchange("binance") as arbitrage_trading_mode_tests.exchange_tuple: _, binance_consumer, _ = arbitrage_trading_mode_tests.exchange_tuple symbol = "BTC/USDT" final_note = None state = trading_enums.EvaluatorStates.SHORT with mock.patch.object(binance_consumer, "_create_initial_arbitrage_order", new=mock.AsyncMock()) as initial_mock, \ mock.patch.object(binance_consumer, "_create_secondary_arbitrage_order", new=mock.AsyncMock()) as secondary_mock: # no data in kwargs with pytest.raises(KeyError): await binance_consumer.create_new_orders(symbol, final_note, state) initial_mock.assert_not_called() secondary_mock.assert_not_called() data = { arbitrage_trading_mode.ArbitrageModeConsumer.ARBITRAGE_PHASE_KEY: arbitrage_trading_mode.ArbitrageModeConsumer.INITIAL_PHASE, arbitrage_trading_mode.ArbitrageModeConsumer.ARBITRAGE_CONTAINER_KEY: None } await binance_consumer.create_new_orders(symbol, final_note, state, data=data) initial_mock.assert_called_once() secondary_mock.assert_not_called() initial_mock.reset_mock() data = { arbitrage_trading_mode.ArbitrageModeConsumer.ARBITRAGE_PHASE_KEY: arbitrage_trading_mode.ArbitrageModeConsumer.SECONDARY_PHASE, arbitrage_trading_mode.ArbitrageModeConsumer.ARBITRAGE_CONTAINER_KEY: None, arbitrage_trading_mode.ArbitrageModeConsumer.QUANTITY_KEY: None } await binance_consumer.create_new_orders(symbol, final_note, state, data=data) initial_mock.assert_not_called() secondary_mock.assert_called_once() async def test_create_initial_arbitrage_order(): async with arbitrage_trading_mode_tests.exchange("binance") as arbitrage_trading_mode_tests.exchange_tuple: _, binance_consumer, _ = arbitrage_trading_mode_tests.exchange_tuple price = decimal.Decimal(10) # long arbitrage = arbitrage_container_import.ArbitrageContainer(price, decimal.Decimal(15), trading_enums.EvaluatorStates.LONG) orders = await binance_consumer._create_initial_arbitrage_order(arbitrage) assert orders order = orders[0] assert order.exchange_order_type is trading_enums.TradeOrderType.LIMIT assert order.order_type is trading_enums.TraderOrderType.BUY_LIMIT assert order.side is trading_enums.TradeOrderSide.BUY assert order.symbol == binance_consumer.trading_mode.symbol assert order.order_id == arbitrage.initial_limit_order_id assert arbitrage in binance_consumer.open_arbitrages # short arbitrage = arbitrage_container_import.ArbitrageContainer(price, decimal.Decimal(15), trading_enums.EvaluatorStates.SHORT) orders = await binance_consumer._create_initial_arbitrage_order(arbitrage) assert orders order = orders[0] assert order.exchange_order_type is trading_enums.TradeOrderType.LIMIT assert order.order_type is trading_enums.TraderOrderType.SELL_LIMIT assert order.side is trading_enums.TradeOrderSide.SELL assert order.symbol == binance_consumer.trading_mode.symbol assert order.order_id == arbitrage.initial_limit_order_id assert arbitrage in binance_consumer.open_arbitrages async def test_create_secondary_arbitrage_order(): async with arbitrage_trading_mode_tests.exchange("binance") as arbitrage_trading_mode_tests.exchange_tuple: _, binance_consumer, exchange_manager = arbitrage_trading_mode_tests.exchange_tuple price = decimal.Decimal(10) # disable inactive orders exchange_manager.trader.enable_inactive_orders = False # long arbitrage = arbitrage_container_import.ArbitrageContainer( price, decimal.Decimal(15), trading_enums.EvaluatorStates.LONG ) quantity = decimal.Decimal(5) orders = await binance_consumer._create_secondary_arbitrage_order(arbitrage, quantity) assert orders limit_order = orders[0] assert limit_order.exchange_order_type is trading_enums.TradeOrderType.LIMIT assert limit_order.order_type is trading_enums.TraderOrderType.SELL_LIMIT assert limit_order.side is trading_enums.TradeOrderSide.SELL assert limit_order.symbol == binance_consumer.trading_mode.symbol assert limit_order.order_id == arbitrage.secondary_limit_order_id assert limit_order.origin_quantity == quantity assert limit_order.associated_entry_ids is None assert limit_order.is_active is True order_group_1 = limit_order.order_group stop_order = order_group_1.get_group_open_orders()[1] assert order_group_1 is stop_order.order_group assert stop_order.exchange_order_type is trading_enums.TradeOrderType.STOP_LOSS assert stop_order.order_type is trading_enums.TraderOrderType.STOP_LOSS assert stop_order.side is trading_enums.TradeOrderSide.SELL assert stop_order.symbol == binance_consumer.trading_mode.symbol assert stop_order.order_id == arbitrage.secondary_stop_order_id assert stop_order.origin_quantity == quantity assert stop_order.is_active is True assert limit_order.associated_entry_ids is None # enable inactive orders exchange_manager.trader.enable_inactive_orders = True # short arbitrage = arbitrage_container_import.ArbitrageContainer( price, decimal.Decimal(15), trading_enums.EvaluatorStates.SHORT ) arbitrage.initial_limit_order_id = "123" quantity = decimal.Decimal(5) orders = await binance_consumer._create_secondary_arbitrage_order(arbitrage, quantity) assert orders limit_order = orders[0] assert limit_order.exchange_order_type is trading_enums.TradeOrderType.LIMIT assert limit_order.order_type is trading_enums.TraderOrderType.BUY_LIMIT assert limit_order.side is trading_enums.TradeOrderSide.BUY assert limit_order.symbol == binance_consumer.trading_mode.symbol assert limit_order.order_id == arbitrage.secondary_limit_order_id assert limit_order.origin_quantity == quantity assert limit_order.is_active is False assert limit_order.associated_entry_ids == ["123"] order_group_2 = limit_order.order_group stop_order = order_group_2.get_group_open_orders()[1] assert order_group_2 is stop_order.order_group assert order_group_2 != order_group_1 assert stop_order.exchange_order_type is trading_enums.TradeOrderType.STOP_LOSS assert stop_order.order_type is trading_enums.TraderOrderType.STOP_LOSS assert stop_order.side is trading_enums.TradeOrderSide.BUY assert stop_order.symbol == binance_consumer.trading_mode.symbol assert stop_order.order_id == arbitrage.secondary_stop_order_id assert stop_order.origin_quantity == quantity assert stop_order.is_active is True assert limit_order.associated_entry_ids == ["123"] async def test_get_quantity_from_holdings(): async with arbitrage_trading_mode_tests.exchange("binance") as arbitrage_trading_mode_tests.exchange_tuple: _, binance_consumer, _ = arbitrage_trading_mode_tests.exchange_tuple binance_consumer.PORTFOLIO_PERCENT_PER_TRADE = decimal.Decimal(str(0.5)) assert binance_consumer._get_quantity_from_holdings(decimal.Decimal(str(10)), decimal.Decimal(str(100)), trading_enums.EvaluatorStates.SHORT) == decimal.Decimal(str(5)) assert binance_consumer._get_quantity_from_holdings(decimal.Decimal(str(10)), decimal.Decimal(str(100)), trading_enums.EvaluatorStates.LONG) == decimal.Decimal(str(50)) async def test_get_stop_loss_price(): async with arbitrage_trading_mode_tests.exchange("binance") as arbitrage_trading_mode_tests.exchange_tuple: _, binance_consumer, arbitrage_trading_mode_tests.exchange_manager = arbitrage_trading_mode_tests.exchange_tuple binance_consumer.STOP_LOSS_DELTA_FROM_OWN_PRICE = decimal.Decimal(str(0.01)) symbol_market = arbitrage_trading_mode_tests.exchange_manager.exchange.get_market_status("BTC/USDT", with_fixer=False) assert binance_consumer._get_stop_loss_price(symbol_market, decimal.Decimal(str(100)), True) == decimal.Decimal(str(99)) assert binance_consumer._get_stop_loss_price(symbol_market, decimal.Decimal(str(100)), False) == decimal.Decimal(str(101)) ================================================ FILE: Trading/Mode/arbitrage_trading_mode/tests/test_arbitrage_trading_mode_producer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import decimal import octobot_commons.pretty_printer as pretty_printer import octobot_trading.enums as trading_enums import tentacles.Trading.Mode.arbitrage_trading_mode.arbitrage_container as arbitrage_container_import import tentacles.Trading.Mode.arbitrage_trading_mode.tests as arbitrage_trading_mode_tests import octobot_tentacles_manager.api as tentacles_manager_api # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_init(): tentacles_manager_api.reload_tentacle_info() async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple # producer assert binance_producer.own_exchange_mark_price is None assert binance_producer.other_exchanges_mark_prices == {} assert binance_producer.sup_triggering_price_delta_ratio > 1 assert binance_producer.inf_triggering_price_delta_ratio < 1 assert binance_producer.base assert binance_producer.quote assert binance_producer.lock async def test_own_exchange_mark_price_callback(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, _, _ = exchange_tuple with mock.patch.object(binance_producer, "_create_arbitrage_initial_order", new=mock.AsyncMock()) as order_mock: # no other exchange mark price yet await binance_producer._own_exchange_mark_price_callback("", "", "", "", 11) assert binance_producer.own_exchange_mark_price == decimal.Decimal(11) order_mock.assert_not_called() binance_producer.other_exchanges_mark_prices["kraken"] = decimal.Decimal(20) binance_producer.other_exchanges_mark_prices["bitfinex"] = decimal.Decimal(22) # other exchange mark price is set await binance_producer._own_exchange_mark_price_callback("", "", "", "", 11) order_mock.assert_called_once() async def test_mark_price_callback(): binance = "binance" kraken = "kraken" async with arbitrage_trading_mode_tests.exchange(binance) as binance_tuple, \ arbitrage_trading_mode_tests.exchange(kraken, backtesting=binance_tuple[2].backtesting) as kraken_tuple: binance_producer, _, _ = binance_tuple kraken_producer, _, _ = kraken_tuple with mock.patch.object(binance_producer, "_create_arbitrage_initial_order", new=mock.AsyncMock()) as binance_order_mock, \ mock.patch.object(kraken_producer, "_create_arbitrage_initial_order", new=mock.AsyncMock()) as kraken_order_mock: # no own exchange price yet await kraken_producer._mark_price_callback(binance, "", "", "", 1000) kraken_order_mock.assert_not_called() await binance_producer._mark_price_callback(kraken, "", "", "", 1000) binance_order_mock.assert_not_called() # set own exchange mark price on kraken kraken_producer.own_exchange_mark_price = decimal.Decimal(900) # no effect on binance await binance_producer._mark_price_callback(kraken, "", "", "", 1000) binance_order_mock.assert_not_called() # create arbitrage on kraken await kraken_producer._mark_price_callback(binance, "", "", "", 1000) kraken_order_mock.assert_called_once() async def test_order_filled_callback(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple order_id = "1" price = 10 quantity = 3 fees = 0.1 fees_currency = "BTC" symbol = "BTC/USD" order_dict = get_order_dict(order_id, symbol, price, quantity, trading_enums.OrderStatus.FILLED.value, trading_enums.TradeOrderType.LIMIT.value, fees, fees_currency) with mock.patch.object(binance_producer, "_close_arbitrage", new=mock.Mock()) as close_mock, \ mock.patch.object(binance_producer, "_trigger_arbitrage_secondary_order", new=mock.AsyncMock()) as trigger_mock, \ mock.patch.object(binance_producer, "_log_results", new=mock.Mock()) as result_mock: # nothing happens: order id not in open arbitrages await binance_producer.order_filled_callback(order_dict) close_mock.assert_not_called() trigger_mock.assert_not_called() # order id now in open arbitrages arbitrage = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(price)), decimal.Decimal(15), trading_enums.EvaluatorStates.LONG) arbitrage.initial_limit_order_id = order_id binance_consumer.open_arbitrages.append(arbitrage) await binance_producer.order_filled_callback(order_dict) close_mock.assert_not_called() result_mock.assert_not_called() # call create secondary order trigger_mock.assert_called_once() trigger_mock.reset_mock() # last step case 1: close arbitrage: fill callback with secondary limit order limit_id = "2" arbitrage.passed_initial_order = True arbitrage.secondary_limit_order_id = limit_id arbitrage.initial_before_fee_filled_quantity = decimal.Decimal(str(29.9)) sec_limit_order_dict = get_order_dict(limit_id, symbol, price, quantity, trading_enums.OrderStatus.FILLED.value, trading_enums.TradeOrderType.LIMIT.value, fees, fees_currency) await binance_producer.order_filled_callback(sec_limit_order_dict) # call close arbitrage close_mock.assert_called_once() trigger_mock.assert_not_called() result_mock.assert_called_once() _, arbitrage_success, filled_quantity = result_mock.mock_calls[0].args assert arbitrage_success assert filled_quantity == quantity * price close_mock.reset_mock() result_mock.reset_mock() # last step case 2: close arbitrage: fill callback with secondary stop order stop_id = "3" arbitrage.secondary_stop_order_id = stop_id sec_stop_order_dict = get_order_dict(stop_id, symbol, price, quantity, trading_enums.OrderStatus.FILLED.value, trading_enums.TradeOrderType.STOP_LOSS.value, fees, fees_currency) await binance_producer.order_filled_callback(sec_stop_order_dict) # call close arbitrage close_mock.assert_called_once() result_mock.assert_called_once() _, arbitrage_success, filled_quantity = result_mock.mock_calls[0].args assert not arbitrage_success assert filled_quantity == quantity * price trigger_mock.assert_not_called() async def test_order_cancelled_callback(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple order_id = "1" price = 10 quantity = 3 fees = 0.1 fees_currency = "BTC" symbol = "BTC/USD" order_dict = get_order_dict(order_id, symbol, price, quantity, trading_enums.OrderStatus.FILLED.value, trading_enums.TradeOrderType.LIMIT.value, fees, fees_currency) with mock.patch.object(binance_producer, "_close_arbitrage", new=mock.Mock()) as close_mock: # no open arbitrage await binance_producer.order_cancelled_callback(order_dict) close_mock.assert_not_called() # open arbitrage with different order id: nothing happens arbitrage = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(price)), decimal.Decimal(15), trading_enums.EvaluatorStates.LONG) binance_consumer.open_arbitrages.append(arbitrage) await binance_producer.order_cancelled_callback(order_dict) close_mock.assert_not_called() # open arbitrage with this order id: arbitrage gets closed arbitrage.initial_limit_order_id = order_id await binance_producer.order_cancelled_callback(order_dict) close_mock.assert_called_once() async def test_analyse_arbitrage_opportunities(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, _, _ = exchange_tuple with mock.patch.object(binance_producer, "_ensure_no_expired_opportunities", new=mock.AsyncMock()) as expiration_mock, \ mock.patch.object(binance_producer, "_trigger_arbitrage_opportunity", new=mock.AsyncMock()) as trigger_mock: # long opportunity 1 binance_producer.own_exchange_mark_price = decimal.Decimal(str(10)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(100)), "binanceje": decimal.Decimal(str(200)), "bitfinex": decimal.Decimal(str(150))} # long enabled await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_called_once_with(decimal.Decimal(str(150)), trading_enums.EvaluatorStates.LONG) trigger_mock.assert_called_once_with(decimal.Decimal(str(150)), trading_enums.EvaluatorStates.LONG) expiration_mock.reset_mock() trigger_mock.reset_mock() # long disabled binance_producer.enable_longs = False await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() # short opportunity 1 binance_producer.own_exchange_mark_price = decimal.Decimal(str(100)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(70)), "binanceje": decimal.Decimal(str(71)), "bitfinex": decimal.Decimal(str(75))} # short enabled await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_called_once_with(decimal.Decimal(str(72)), trading_enums.EvaluatorStates.SHORT) trigger_mock.assert_called_once_with(decimal.Decimal(str(72)), trading_enums.EvaluatorStates.SHORT) expiration_mock.reset_mock() trigger_mock.reset_mock() # short disabled binance_producer.enable_shorts = False await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() binance_producer.enable_longs = True binance_producer.enable_shorts = True # long opportunity but price too close to current price binance_producer.own_exchange_mark_price = decimal.Decimal(str(71.99)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(70)), "binanceje": decimal.Decimal(str(71)), "bitfinex": decimal.Decimal(str(75))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() # short opportunity but price too close to current price binance_producer.own_exchange_mark_price = decimal.Decimal(str(72.01)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(70)), "binanceje": decimal.Decimal(str(71)), "bitfinex": decimal.Decimal(str(75))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() # with higher numbers # higher numbers long opportunity # max long exclusive trigger should be 9803.921568627451 on own_exchange_mark_price binance_producer.own_exchange_mark_price = decimal.Decimal(str(9802.9999)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(9000)), "binanceje": decimal.Decimal(str(10000)), "bitfinex": decimal.Decimal(str(11000))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_called_once_with(decimal.Decimal(str(10000)), trading_enums.EvaluatorStates.LONG) trigger_mock.assert_called_once_with(decimal.Decimal(str(10000)), trading_enums.EvaluatorStates.LONG) expiration_mock.reset_mock() trigger_mock.reset_mock() # higher numbers long opportunity: fail to pass threshold 1 # max long exclusive trigger should be 9803.921568627451 on own_exchange_mark_price binance_producer.own_exchange_mark_price = decimal.Decimal(str(9803.921568627451)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(9000)), "binanceje": decimal.Decimal(str(10000)), "bitfinex": decimal.Decimal(str(11000))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() expiration_mock.reset_mock() trigger_mock.reset_mock() # higher numbers long opportunity: fail to pass threshold 2 # max long exclusive trigger should be 9803.921568627451 on own_exchange_mark_price binance_producer.own_exchange_mark_price = decimal.Decimal(str(9803.9216)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(9000)), "binanceje": decimal.Decimal(str(10000)), "bitfinex": decimal.Decimal(str(11000))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() expiration_mock.reset_mock() trigger_mock.reset_mock() # higher numbers short opportunity # min short exclusive trigger should be 10204.081632653062 on own_exchange_mark_price binance_producer.own_exchange_mark_price = decimal.Decimal(str(10205)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(9000)), "binanceje": decimal.Decimal(str(10000)), "bitfinex": decimal.Decimal(str(11000))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_called_once_with(decimal.Decimal(str(10000)), trading_enums.EvaluatorStates.SHORT) trigger_mock.assert_called_once_with(decimal.Decimal(str(10000)), trading_enums.EvaluatorStates.SHORT) expiration_mock.reset_mock() trigger_mock.reset_mock() # higher numbers short opportunity: fail to pass threshold 1 # min short exclusive trigger should be 10204.081632653062 on own_exchange_mark_price binance_producer.own_exchange_mark_price = decimal.Decimal(str(10203.081632653062)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(9000)), "binanceje": decimal.Decimal(str(10000)), "bitfinex": decimal.Decimal(str(11000))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() expiration_mock.reset_mock() trigger_mock.reset_mock() # higher numbers short opportunity: fail to pass threshold 2 # min short exclusive trigger should be 10204.081632653062 on own_exchange_mark_price binance_producer.own_exchange_mark_price = decimal.Decimal(str(10204.0815)) binance_producer.other_exchanges_mark_prices = {"kraken": decimal.Decimal(str(9000)), "binanceje": decimal.Decimal(str(10000)), "bitfinex": decimal.Decimal(str(11000))} await binance_producer._analyse_arbitrage_opportunities() expiration_mock.assert_not_called() trigger_mock.assert_not_called() expiration_mock.reset_mock() trigger_mock.reset_mock() async def test_trigger_arbitrage_opportunity(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, _, _ = exchange_tuple with mock.patch.object(binance_producer, "_create_arbitrage_initial_order", new=mock.AsyncMock()) as order_mock, \ mock.patch.object(binance_producer, "_register_state", new=mock.Mock()) as register_mock, \ mock.patch.object(binance_producer, "_log_arbitrage_opportunity_details", new=mock.Mock()) as \ log_arbitrage_opportunity_details_mock: binance_producer.own_exchange_mark_price = decimal.Decimal(str(10)) await binance_producer._trigger_arbitrage_opportunity(15, trading_enums.EvaluatorStates.LONG) order_mock.assert_called_once() register_mock.assert_called_once_with(trading_enums.EvaluatorStates.LONG, decimal.Decimal(str(5))) log_arbitrage_opportunity_details_mock.assert_called_once_with(decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) async def test_log_arbitrage_opportunity_details(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, _, _ = exchange_tuple binance_producer.own_exchange_mark_price = decimal.Decimal(str(100)) debug_mock = mock.Mock() # do not mock with context manager to keep the mock in teardown binance_producer.logger = debug_mock binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(99.999)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(-0.001)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(90)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(-10)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(1)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(-99)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(0)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(-100)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(0)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(0)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(100.00001)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(0.00001)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(110)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(10)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(150)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(50)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(250)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(150)}%" in debug_mock.debug.call_args[0][0] binance_producer._log_arbitrage_opportunity_details(decimal.Decimal(str(20100)), trading_enums.EvaluatorStates.LONG) assert f"{pretty_printer.round_with_decimal_count(20000)}%" in debug_mock.debug.call_args[0][0] async def test_trigger_arbitrage_secondary_order(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, _, _ = exchange_tuple order_id = "1" price = 10 quantity = 3 fees = 0.1 fees_currency = "BTC" symbol = "BTC/USDT" order_dict = get_order_dict(order_id, symbol, price, quantity, trading_enums.OrderStatus.FILLED.value, trading_enums.TradeOrderType.LIMIT.value, fees, fees_currency) with mock.patch.object(binance_producer, "_create_arbitrage_secondary_order", new=mock.AsyncMock()) as order_mock: # long: already bought, is now selling arbitrage = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(price)), decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) await binance_producer._trigger_arbitrage_secondary_order(arbitrage, order_dict, 3) updated_arbitrage, secondary_quantity = order_mock.mock_calls[0].args assert updated_arbitrage is arbitrage assert arbitrage.passed_initial_order assert arbitrage.initial_before_fee_filled_quantity == decimal.Decimal(str(30)) assert secondary_quantity == decimal.Decimal(str(2.9)) order_mock.reset_mock() # short: already sold, is now buying: no fee on base side arbitrage_2 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(price)), decimal.Decimal(str(7)), trading_enums.EvaluatorStates.SHORT) await binance_producer._trigger_arbitrage_secondary_order(arbitrage_2, order_dict, 3) updated_arbitrage, secondary_quantity = order_mock.mock_calls[0].args assert updated_arbitrage is arbitrage_2 assert arbitrage_2.passed_initial_order assert arbitrage_2.initial_before_fee_filled_quantity == decimal.Decimal(str(3)) assert round(secondary_quantity, 5) == decimal.Decimal("4.14286") order_mock.reset_mock() # short: already sold, is now buying: fee on base side arbitrage_3 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(price)), decimal.Decimal(str(7)), trading_enums.EvaluatorStates.SHORT) order_dict = get_order_dict(order_id, symbol, price, quantity, trading_enums.OrderStatus.FILLED.value, trading_enums.TradeOrderType.STOP_LOSS.value, fees, "USDT") await binance_producer._trigger_arbitrage_secondary_order(arbitrage_3, order_dict, 3) updated_arbitrage, secondary_quantity = order_mock.mock_calls[0].args assert updated_arbitrage is arbitrage_3 assert arbitrage_3.passed_initial_order assert arbitrage_3.initial_before_fee_filled_quantity == decimal.Decimal(str(3)) assert round(secondary_quantity, 5) == decimal.Decimal("4.27143") async def test_ensure_no_existing_arbitrage_on_this_price(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple arbitrage_1 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(10)), decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) arbitrage_2 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(20)), decimal.Decimal(str(18)), trading_enums.EvaluatorStates.SHORT) binance_consumer.open_arbitrages = [arbitrage_1, arbitrage_2] binance_producer.own_exchange_mark_price = 9 assert binance_producer._ensure_no_existing_arbitrage_on_this_price(trading_enums.EvaluatorStates.LONG) assert binance_producer._ensure_no_existing_arbitrage_on_this_price(trading_enums.EvaluatorStates.SHORT) for price in (9.99, 10, 11, 15): binance_producer.own_exchange_mark_price = decimal.Decimal(str(price)) assert not binance_producer._ensure_no_existing_arbitrage_on_this_price(trading_enums.EvaluatorStates.LONG) assert binance_producer._ensure_no_existing_arbitrage_on_this_price(trading_enums.EvaluatorStates.SHORT) for price in (18, 17.99, 20, 20.001): binance_producer.own_exchange_mark_price = decimal.Decimal(str(price)) assert binance_producer._ensure_no_existing_arbitrage_on_this_price(trading_enums.EvaluatorStates.LONG) assert not binance_producer._ensure_no_existing_arbitrage_on_this_price(trading_enums.EvaluatorStates.SHORT) async def test_get_arbitrage(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple arbitrage_1 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(10)), decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) arbitrage_2 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(20)), decimal.Decimal(str(18)), trading_enums.EvaluatorStates.SHORT) binance_consumer.open_arbitrages = [arbitrage_1, arbitrage_2] arbitrage_1.initial_limit_order_id = "1" assert arbitrage_1 is binance_producer._get_arbitrage("1") assert None is binance_producer._get_arbitrage("2") async def test_ensure_no_expired_opportunities(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, exchange_manager = exchange_tuple arbitrage_1 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(10)), decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) arbitrage_2 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(20)), decimal.Decimal(str(17)), trading_enums.EvaluatorStates.SHORT) binance_consumer.open_arbitrages = [arbitrage_1, arbitrage_2] with mock.patch.object(binance_producer, "_cancel_order", new=mock.AsyncMock()) as cancel_order_mock: # average price is 18 # long order is valid # short order is expired (price > 17) await binance_producer._ensure_no_expired_opportunities(decimal.Decimal(str(18)), trading_enums.EvaluatorStates.LONG) assert arbitrage_2 not in binance_consumer.open_arbitrages cancel_order_mock.assert_called_once() cancel_order_mock.reset_mock() await binance_producer._ensure_no_expired_opportunities(decimal.Decimal(str(18)), trading_enums.EvaluatorStates.SHORT) assert binance_consumer.open_arbitrages == [arbitrage_1] cancel_order_mock.assert_not_called() async def test_close_arbitrage(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple arbitrage_1 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(10)), decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) arbitrage_2 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(20)), decimal.Decimal(str(17)), trading_enums.EvaluatorStates.SHORT) binance_consumer.open_arbitrages = [arbitrage_1, arbitrage_2] binance_producer._close_arbitrage(arbitrage_1) assert arbitrage_1 not in binance_consumer.open_arbitrages assert binance_producer.state is trading_enums.EvaluatorStates.NEUTRAL assert binance_producer.final_eval == "" async def test_get_open_arbitrages(): binance = "binance" kraken = "kraken" async with arbitrage_trading_mode_tests.exchange(binance) as binance_tuple, \ arbitrage_trading_mode_tests.exchange(kraken, backtesting=binance_tuple[2].backtesting) as kraken_tuple: binance_producer, binance_consumer, _ = binance_tuple kraken_producer, kraken_consumer, _ = kraken_tuple arbitrage_1 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(10)), decimal.Decimal(str(15)), trading_enums.EvaluatorStates.LONG) arbitrage_2 = arbitrage_container_import.ArbitrageContainer(decimal.Decimal(str(20)), decimal.Decimal(str(17)), trading_enums.EvaluatorStates.SHORT) binance_consumer.open_arbitrages = [arbitrage_1, arbitrage_2] assert kraken_consumer.open_arbitrages == [] assert binance_producer._get_open_arbitrages() is binance_consumer.open_arbitrages assert kraken_producer._get_open_arbitrages() is kraken_consumer.open_arbitrages async def test_register_state(): async with arbitrage_trading_mode_tests.exchange("binance") as exchange_tuple: binance_producer, binance_consumer, _ = exchange_tuple assert binance_producer.state is trading_enums.EvaluatorStates.NEUTRAL binance_producer._register_state(trading_enums.EvaluatorStates.LONG, decimal.Decimal(str(1))) assert binance_producer.state is trading_enums.EvaluatorStates.LONG assert "1" in binance_producer.final_eval def get_order_dict(order_id, symbol, price, quantity, status, order_type, fees_amount, fees_currency): return { trading_enums.ExchangeConstantsOrderColumns.ID.value: order_id, trading_enums.ExchangeConstantsOrderColumns.SYMBOL.value: symbol, trading_enums.ExchangeConstantsOrderColumns.PRICE.value: price, trading_enums.ExchangeConstantsOrderColumns.AMOUNT.value: quantity, trading_enums.ExchangeConstantsOrderColumns.FILLED.value: quantity, trading_enums.ExchangeConstantsOrderColumns.STATUS.value: status, trading_enums.ExchangeConstantsOrderColumns.TYPE.value: order_type, trading_enums.ExchangeConstantsOrderColumns.FEE.value: { trading_enums.FeePropertyColumns.CURRENCY.value: fees_currency, trading_enums.FeePropertyColumns.COST.value: fees_amount, trading_enums.FeePropertyColumns.IS_FROM_EXCHANGE.value: True }, } ================================================ FILE: Trading/Mode/blank_trading_mode/__init__.py ================================================ from .blank_trading import BlankTradingMode ================================================ FILE: Trading/Mode/blank_trading_mode/blank_trading.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import octobot_trading.enums as enums import octobot_trading.modes as trading_modes class BlankTradingMode(trading_modes.AbstractTradingMode): """ This trading mode is doing nothing. It is to be selected when no trading is to be done. """ @staticmethod def is_backtestable(): return False @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ enums.ExchangeTypes.SPOT, enums.ExchangeTypes.FUTURE ] ================================================ FILE: Trading/Mode/blank_trading_mode/config/BlankTradingMode.json ================================================ { "default_config": [ "BlankStrategyEvaluator" ], "required_strategies": [ "BlankStrategyEvaluator" ] } ================================================ FILE: Trading/Mode/blank_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["BlankTradingMode"], "tentacles-requirements": ["blank_strategy"] } ================================================ FILE: Trading/Mode/blank_trading_mode/resources/BlankTradingMode.md ================================================ Blank trading mode is not trading. Activate it to disable new order creation on your OctoBot. ================================================ FILE: Trading/Mode/daily_trading_mode/__init__.py ================================================ from .daily_trading import DailyTradingMode ================================================ FILE: Trading/Mode/daily_trading_mode/config/DailyTradingMode.json ================================================ { "default_config": [ "SimpleStrategyEvaluator" ], "required_strategies": [ "SimpleStrategyEvaluator", "TechnicalAnalysisStrategyEvaluator" ], "required_strategies_min_count": 1, "target_profits_mode": false, "use_prices_close_to_current_price": false, "close_to_current_price_difference": 0.005, "target_profits_mode_take_profit": 5, "target_profits_mode_enable_position_increase": false, "use_stop_orders": true, "target_profits_mode_stop_loss": 2.5, "buy_with_maximum_size_orders": false, "sell_with_maximum_size_orders": false, "buy_order_amount": "", "sell_order_amount": "", "disable_sell_orders": false, "disable_buy_orders": false, "max_currency_percent": 100, "emit_trading_signals": false } ================================================ FILE: Trading/Mode/daily_trading_mode/daily_trading.pxd ================================================ # cython: language_level=3 # Drakkar-Software OctoBot-Trading # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. from octobot_trading.producers.abstract_mode_producer cimport AbstractTradingModeProducer from octobot_trading.consumers.abstract_mode_consumer cimport AbstractTradingModeConsumer from octobot_trading.modes.abstract_trading_mode cimport AbstractTradingMode cdef class DailyTradingMode(AbstractTradingMode): pass cdef class DailyTradingModeConsumer(AbstractTradingModeConsumer): cdef public double MAX_SUM_RESULT cdef public double STOP_LOSS_ORDER_MAX_PERCENT cdef public double STOP_LOSS_ORDER_MIN_PERCENT cdef public double STOP_LOSS_ORDER_ATTENUATION cdef public double QUANTITY_MIN_PERCENT cdef public double QUANTITY_MAX_PERCENT cdef public double QUANTITY_ATTENUATION cdef public double QUANTITY_MARKET_MIN_PERCENT cdef public double QUANTITY_MARKET_MAX_PERCENT cdef public double QUANTITY_BUY_MARKET_ATTENUATION cdef public double QUANTITY_MARKET_ATTENUATION cdef public double BUY_LIMIT_ORDER_MAX_PERCENT cdef public double BUY_LIMIT_ORDER_MIN_PERCENT cdef public double SELL_LIMIT_ORDER_MIN_PERCENT cdef public double SELL_LIMIT_ORDER_MAX_PERCENT cdef public double LIMIT_ORDER_ATTENUATION cdef public double QUANTITY_RISK_WEIGHT cdef public double MAX_QUANTITY_RATIO cdef public double MIN_QUANTITY_RATIO cdef public double DELTA_RATIO cdef public double SELL_MULTIPLIER cdef public double FULL_SELL_MIN_RATIO cdef public bint USE_CLOSE_TO_CURRENT_PRICE cdef public double CLOSE_TO_CURRENT_PRICE_DEFAULT_RATIO cdef public bint BUY_WITH_MAXIMUM_SIZE_ORDERS cdef public bint SELL_WITH_MAXIMUM_SIZE_ORDERS cdef public bint DISABLE_BUY_ORDERS cdef public bint DISABLE_SELL_ORDERS cdef public bint USE_STOP_ORDERS cpdef __get_limit_price_from_risk(self, object eval_note) cpdef __get_stop_price_from_risk(self) cpdef __get_buy_limit_quantity_from_risk(self, object eval_note, double quantity, str quote) cpdef __get_market_quantity_from_risk(self, object eval_note, double quantity, str quote, bint selling=*) cdef class DailyTradingModeProducer(AbstractTradingModeProducer): cdef public object state cdef public double VERY_LONG_THRESHOLD cdef public double LONG_THRESHOLD cdef public double NEUTRAL_THRESHOLD cdef public double SHORT_THRESHOLD cdef public double RISK_THRESHOLD cpdef double __get_delta_risk(self) ================================================ FILE: Trading/Mode/daily_trading_mode/daily_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import decimal import math import dataclasses import typing import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.evaluators_util as evaluators_util import octobot_commons.pretty_printer as pretty_printer import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.signals as signals import octobot_evaluators.api as evaluators_api import octobot_evaluators.constants as evaluators_constants import octobot_evaluators.enums as evaluators_enums import octobot_evaluators.matrix as matrix import octobot_trading.constants as trading_constants import octobot_trading.errors as trading_errors import octobot_trading.api as trading_api import octobot_trading.modes as trading_modes import octobot_trading.modes.script_keywords as script_keywords import octobot_trading.enums as trading_enums import octobot_trading.personal_data as trading_personal_data @dataclasses.dataclass class OrderDetails: price: decimal.Decimal quantity: typing.Optional[decimal.Decimal] class DailyTradingMode(trading_modes.AbstractTradingMode): def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.UI.user_input( "target_profits_mode", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Target profits mode: Enable target profits mode. In this mode, only entry " "signals are taken into account (usually LONG signals). When an entry is filled, " "a take profit will instantly be created using the '[Target profits mode] Take profit' setting. " "A stop loss can also be created using the '[Target profits mode] Stop loss' setting if " "'Stop orders' are enabled.", ) self.UI.user_input( "use_prices_close_to_current_price", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Fixed limit prices: Use a fixed ratio to compute prices in sell / buy orders.", ) self.UI.user_input( "close_to_current_price_difference", commons_enums.UserInputTypes.FLOAT, 0.005, inputs, min_val=0, title="Fixed limit prices difference: Multiplier to take into account when placing a limit order " "(used if fixed limit prices is enabled). For a 200 USD price and 0.005 in difference: " "buy price would be 199 and sell price 201.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "use_prices_close_to_current_price": True } } ) self.UI.user_input( "target_profits_mode_take_profit", commons_enums.UserInputTypes.FLOAT, 5, inputs, min_val=0, title="[Target profits mode] Take profit: percent profits to compute the take profit order price from. " "Only used in 'Target profits mode'. " "Example: a buy entry at 300 with a 'Take profit' at 10 will create a sell order at 330.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "target_profits_mode": True } } ) self.UI.user_input( "use_stop_orders", commons_enums.UserInputTypes.BOOLEAN, True, inputs, title="Stop orders: Create a stop loss alongside sell orders.", ) self.UI.user_input( "target_profits_mode_stop_loss", commons_enums.UserInputTypes.FLOAT, 2.5, inputs, min_val=0, max_val=100, title="[Target profits mode] Stop loss: maximum percent losses to compute the stop loss price from. " "Only used in 'Target profits mode'. " "Example: a buy entry at 300 with a 'Stop loss' at 10 will create a stop order at 270.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "target_profits_mode": True, "use_stop_orders": True, } } ) self.UI.user_input( "target_profits_mode_enable_position_increase", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="[Target profits mode] Enable futures position increase: Allow to increase a previously open " "position when receiving a new signal. " "Only used in 'Target profits mode' when trading futures. " "Example: increase a $100 LONG position to $150 by adding $50 more when a new LONG signal is " "received. WARNING: enabling this option can lead to liquidation price changes as positions " "build up and end up liquidating a position before initial stop loss prices are reached.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "target_profits_mode": True } } ) self.UI.user_input( "buy_with_maximum_size_orders", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="All in buy trades: Trade with all available funds at each buy order.", ) self.UI.user_input( "sell_with_maximum_size_orders", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="All in sell trades: Trade with all available funds at each sell order.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "target_profits_mode": False } } ) trading_modes.user_select_order_amount( self, inputs, buy_dependencies={"buy_with_maximum_size_orders": False}, sell_dependencies={"target_profits_mode": False, "sell_with_maximum_size_orders": False} ) self.UI.user_input( "disable_sell_orders", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Disable sell orders (sell market and sell limit).", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "target_profits_mode": False } } ) self.UI.user_input( "disable_buy_orders", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Disable buy orders (buy market and buy limit).", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { "target_profits_mode": False } } ) self.UI.user_input( "max_currency_percent", commons_enums.UserInputTypes.FLOAT, 100, inputs, min_val=0, max_val=100, title="Maximum currency percent: Maximum portfolio % to allocate on a given currency. " "Used to compute buy order amounts. Ignored when 'Amount per buy/entry order' is set.", ) @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_current_state(self) -> (str, float): return super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, \ self.producers[0].final_eval def get_mode_producer_classes(self) -> list: return [DailyTradingModeProducer] def get_mode_consumer_classes(self) -> list: return [DailyTradingModeConsumer] @classmethod def get_is_symbol_wildcard(cls) -> bool: return False class DailyTradingModeConsumer(trading_modes.AbstractTradingModeConsumer): PRICE_KEY = "PRICE" VOLUME_KEY = "VOLUME" STOP_PRICE_KEY = "STOP_PRICE" ACTIVE_ORDER_SWAP_STRATEGY = "ACTIVE_ORDER_SWAP_STRATEGY" ACTIVE_ORDER_SWAP_TIMEOUT = "ACTIVE_ORDER_SWAP_TIMEOUT" TAKE_PROFIT_PRICE_KEY = "TAKE_PROFIT_PRICE" ADDITIONAL_TAKE_PROFIT_PRICES_KEY = "ADDITIONAL_TAKE_PROFIT_PRICES" ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY = "ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS" STOP_ONLY = "STOP_ONLY" TRAILING_PROFILE = "TRAILING_PROFILE" CANCEL_POLICY = "CANCEL_POLICY" CANCEL_POLICY_PARAMS = "CANCEL_POLICY_PARAMS" REDUCE_ONLY_KEY = "REDUCE_ONLY" TAG_KEY = "TAG" EXCHANGE_ORDER_IDS = "EXCHANGE_ORDER_IDS" LEVERAGE = "LEVERAGE" ORDER_EXCHANGE_CREATION_PARAMS = "ORDER_EXCHANGE_CREATION_PARAMS" TARGET_PROFIT_MODE_ENTRY_QUANTITY_SIDE = trading_enums.TradeOrderSide.BUY def __init__(self, trading_mode): super().__init__(trading_mode) self.trader = self.exchange_manager.trader self.MAX_SUM_RESULT = decimal.Decimal(2) self.STOP_LOSS_ORDER_MAX_PERCENT = decimal.Decimal(str(0.99)) self.STOP_LOSS_ORDER_MIN_PERCENT = decimal.Decimal(str(0.95)) self.STOP_LOSS_ORDER_ATTENUATION = (self.STOP_LOSS_ORDER_MAX_PERCENT - self.STOP_LOSS_ORDER_MIN_PERCENT) self.QUANTITY_MIN_PERCENT = decimal.Decimal(str(0.1)) self.QUANTITY_MAX_PERCENT = decimal.Decimal(str(0.9)) self.QUANTITY_ATTENUATION = (self.QUANTITY_MAX_PERCENT - self.QUANTITY_MIN_PERCENT) / self.MAX_SUM_RESULT self.QUANTITY_MARKET_MIN_PERCENT = decimal.Decimal(str(0.3)) self.QUANTITY_MARKET_MAX_PERCENT = decimal.Decimal(str(1)) self.QUANTITY_BUY_MARKET_ATTENUATION = decimal.Decimal(str(0.2)) self.QUANTITY_MARKET_ATTENUATION = (self.QUANTITY_MARKET_MAX_PERCENT - self.QUANTITY_MARKET_MIN_PERCENT) \ / self.MAX_SUM_RESULT self.BUY_LIMIT_ORDER_MAX_PERCENT = decimal.Decimal(str(0.995)) self.BUY_LIMIT_ORDER_MIN_PERCENT = decimal.Decimal(str(0.98)) self.SELL_LIMIT_ORDER_MIN_PERCENT = 1 + (1 - self.BUY_LIMIT_ORDER_MAX_PERCENT) self.SELL_LIMIT_ORDER_MAX_PERCENT = 1 + (1 - self.BUY_LIMIT_ORDER_MIN_PERCENT) self.LIMIT_ORDER_ATTENUATION = (self.BUY_LIMIT_ORDER_MAX_PERCENT - self.BUY_LIMIT_ORDER_MIN_PERCENT) \ / self.MAX_SUM_RESULT self.QUANTITY_RISK_WEIGHT = decimal.Decimal(str(0.2)) self.MAX_QUANTITY_RATIO = decimal.Decimal(str(1)) self.MIN_QUANTITY_RATIO = decimal.Decimal(str(0.2)) self.DELTA_RATIO = self.MAX_QUANTITY_RATIO - self.MIN_QUANTITY_RATIO # consider a high ratio not to take too much risk and not to prevent order creation either self.DEFAULT_HOLDING_RATIO = decimal.Decimal(str(0.35)) self.SELL_MULTIPLIER = decimal.Decimal(str(5)) self.FULL_SELL_MIN_RATIO = decimal.Decimal(str(0.05)) trading_config = self.trading_mode.trading_config if self.trading_mode else {} self.USE_TARGET_PROFIT_MODE = trading_config.get("target_profits_mode", False) self.USE_CLOSE_TO_CURRENT_PRICE = trading_config.get("use_prices_close_to_current_price", False) self.CLOSE_TO_CURRENT_PRICE_DEFAULT_RATIO = decimal.Decimal(str( trading_config.get("close_to_current_price_difference") or 0.02 )) self.TARGET_PROFIT_TAKE_PROFIT = decimal.Decimal(str( trading_config.get("target_profits_mode_take_profit") or 5 )) / trading_constants.ONE_HUNDRED self.USE_STOP_ORDERS = trading_config.get("use_stop_orders", True) self.TARGET_PROFIT_STOP_LOSS = decimal.Decimal(str( trading_config.get("target_profits_mode_stop_loss") or 2.5 )) / trading_constants.ONE_HUNDRED self.TARGET_PROFIT_ENABLE_POSITION_INCREASE = trading_config.get( "target_profits_mode_enable_position_increase", False ) self.BUY_WITH_MAXIMUM_SIZE_ORDERS = trading_config.get("buy_with_maximum_size_orders", False) self.SELL_WITH_MAXIMUM_SIZE_ORDERS = trading_config.get("sell_with_maximum_size_orders", False) self.DISABLE_SELL_ORDERS = trading_config.get("disable_sell_orders", False) self.DISABLE_BUY_ORDERS = trading_config.get("disable_buy_orders", False) self.MAX_CURRENCY_RATIO = trading_config.get("max_currency_percent", None) or None if self.MAX_CURRENCY_RATIO is not None: try: self.MAX_CURRENCY_RATIO = decimal.Decimal(str(self.MAX_CURRENCY_RATIO)) / trading_constants.ONE_HUNDRED except decimal.InvalidOperation: self.MAX_CURRENCY_RATIO = None def flush(self): super().flush() self.trader = None """ Starting point : self.SELL_LIMIT_ORDER_MIN_PERCENT or self.BUY_LIMIT_ORDER_MAX_PERCENT 1 - abs(eval_note) --> confirmation level --> high : sell less expensive / buy more expensive 1 - trader.risk --> high risk : sell / buy closer to the current price 1 - abs(eval_note) + 1 - trader.risk --> result between 0 and 2 --> self.MAX_SUM_RESULT self.QUANTITY_ATTENUATION --> try to contains the result between self.XXX_MIN_PERCENT and self.XXX_MAX_PERCENT """ def _get_limit_price_from_risk(self, eval_note): if eval_note > 0: if self.USE_CLOSE_TO_CURRENT_PRICE: return 1 + self.CLOSE_TO_CURRENT_PRICE_DEFAULT_RATIO factor = self.SELL_LIMIT_ORDER_MIN_PERCENT + \ ((1 - abs(eval_note) + 1 - self.trader.risk) * self.LIMIT_ORDER_ATTENUATION) return trading_modes.check_factor(self.SELL_LIMIT_ORDER_MIN_PERCENT, self.SELL_LIMIT_ORDER_MAX_PERCENT, factor) else: if self.USE_CLOSE_TO_CURRENT_PRICE: return 1 - self.CLOSE_TO_CURRENT_PRICE_DEFAULT_RATIO factor = self.BUY_LIMIT_ORDER_MAX_PERCENT - \ ((1 - abs(eval_note) + 1 - self.trader.risk) * self.LIMIT_ORDER_ATTENUATION) return trading_modes.check_factor(self.BUY_LIMIT_ORDER_MIN_PERCENT, self.BUY_LIMIT_ORDER_MAX_PERCENT, factor) """ Starting point : self.STOP_LOSS_ORDER_MAX_PERCENT trader.risk --> low risk : stop level close to the current price self.STOP_LOSS_ORDER_ATTENUATION --> try to contains the result between self.STOP_LOSS_ORDER_MIN_PERCENT and self.STOP_LOSS_ORDER_MAX_PERCENT """ def _get_stop_price_from_risk(self, is_long): max_percent = self.STOP_LOSS_ORDER_MAX_PERCENT if is_long \ else 2 * trading_constants.ONE - self.STOP_LOSS_ORDER_MIN_PERCENT min_percent = self.STOP_LOSS_ORDER_MIN_PERCENT if is_long \ else 2 * trading_constants.ONE - self.STOP_LOSS_ORDER_MAX_PERCENT risk_difference = self.trader.risk * self.STOP_LOSS_ORDER_ATTENUATION factor = max_percent - risk_difference if is_long else min_percent + risk_difference return trading_modes.check_factor(min_percent, max_percent, factor) async def _get_limit_quantity_from_risk(self, ctx, eval_note, max_quantity, base, selling, increasing_position): order_side = trading_enums.TradeOrderSide.SELL if selling else trading_enums.TradeOrderSide.BUY # check all in orders if (selling and self.SELL_WITH_MAXIMUM_SIZE_ORDERS) or (not selling and self.BUY_WITH_MAXIMUM_SIZE_ORDERS): return max_quantity # check configured quantity max_amount_from_ratio = self._get_max_amount_from_max_ratio( self.MAX_CURRENCY_RATIO, max_quantity, base, self.QUANTITY_MAX_PERCENT ) if increasing_position else max_quantity if user_amount := trading_modes.get_user_selected_order_amount( self.trading_mode, self.TARGET_PROFIT_MODE_ENTRY_QUANTITY_SIDE if self.USE_TARGET_PROFIT_MODE else order_side ): user_input_amount = await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=user_amount, side=order_side.value, reduce_only=False, is_stop_order=False, use_total_holding=False, ) return min(user_input_amount, max_amount_from_ratio) # get quantity from risk weighted_risk = self.trader.risk * self.QUANTITY_RISK_WEIGHT if ( # consider sell quantity like a buy if base is the reference market selling and base != self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market and not increasing_position ) or ( # consider buy quantity like a sell if quote is the reference market not selling and base == self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market and increasing_position ): weighted_risk *= self.SELL_MULTIPLIER if not increasing_position and self._get_ratio(base) < self.FULL_SELL_MIN_RATIO: return max_quantity factor = self.QUANTITY_MIN_PERCENT + ((abs(eval_note) + weighted_risk) * self.QUANTITY_ATTENUATION) checked_factor = trading_modes.check_factor(self.QUANTITY_MIN_PERCENT, self.QUANTITY_MAX_PERCENT, factor) holding_ratio = self._get_quantity_ratio(base) if increasing_position else trading_constants.ONE return min(checked_factor * max_quantity * holding_ratio, max_amount_from_ratio) """ Starting point : self.QUANTITY_MARKET_MIN_PERCENT abs(eval_note) --> confirmation level --> high : sell/buy more quantity trader.risk --> high risk : sell / buy more quantity use SELL_MULTIPLIER to increase sell volume relatively to risk abs(eval_note) + trader.risk --> result between 0 and 1 + self.QUANTITY_RISK_WEIGHT --> self.MAX_SUM_RESULT self.QUANTITY_MARKET_ATTENUATION --> try to contains the result between self.QUANTITY_MARKET_MIN_PERCENT and self.QUANTITY_MARKET_MAX_PERCENT """ async def _get_market_quantity_from_risk(self, ctx, eval_note, max_quantity, base, selling, increasing_position): # check all in orders if (selling and self.SELL_WITH_MAXIMUM_SIZE_ORDERS) or (not selling and self.BUY_WITH_MAXIMUM_SIZE_ORDERS): return max_quantity # check configured quantity side = self.TARGET_PROFIT_MODE_ENTRY_QUANTITY_SIDE if self.USE_TARGET_PROFIT_MODE else ( trading_enums.TradeOrderSide.SELL if selling else trading_enums.TradeOrderSide.BUY ) max_amount_from_ratio = ( self._get_max_amount_from_max_ratio( self.MAX_CURRENCY_RATIO, max_quantity, base, self.QUANTITY_MARKET_MAX_PERCENT ) if increasing_position else max_quantity * self.QUANTITY_MARKET_MAX_PERCENT ) if user_amount := trading_modes.get_user_selected_order_amount(self.trading_mode, side): user_input_amount = await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=user_amount, side=side.value, reduce_only=False, is_stop_order=False, use_total_holding=False, ) return min(user_input_amount, max_amount_from_ratio) # get quantity from risk weighted_risk = self.trader.risk * self.QUANTITY_RISK_WEIGHT ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market if (not increasing_position and base != ref_market) or (increasing_position and base == ref_market): weighted_risk *= self.SELL_MULTIPLIER factor = self.QUANTITY_MARKET_MIN_PERCENT + (abs(eval_note) + weighted_risk) * self.QUANTITY_MARKET_ATTENUATION checked_factor = trading_modes.check_factor( self.QUANTITY_MARKET_MIN_PERCENT, self.QUANTITY_MARKET_MAX_PERCENT, factor ) holding_ratio = 1 if not increasing_position else self._get_quantity_ratio(base) return min(checked_factor * holding_ratio * max_quantity, max_amount_from_ratio) def _get_ratio(self, currency): try: return self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio(currency) except trading_errors.MissingPriceDataError: # Can happen when ref market is not in the pair, data will be available later (ticker is now registered) return self.DEFAULT_HOLDING_RATIO def _get_quantity_ratio(self, currency): if self.get_number_of_traded_assets() > 2: ratio = self._get_ratio(currency) # returns a linear result between self.MIN_QUANTITY_RATIO and self.MAX_QUANTITY_RATIO: closer to # self.MAX_QUANTITY_RATIO when holdings are lower in % and to self.MIN_QUANTITY_RATIO when holdings # are higher in % return 1 - min(ratio * self.DELTA_RATIO, 1) else: return 1 def _get_max_amount_from_max_ratio(self, max_ratio, quantity, currency, default_ratio): # TODO ratios in futures trading # reduce max amount when self.MAX_CURRENCY_RATIO is defined if self.MAX_CURRENCY_RATIO is None or max_ratio == trading_constants.ONE or self.exchange_manager.is_future: return quantity * default_ratio max_amount_ratio = max_ratio - self._get_ratio(currency) if max_amount_ratio > 0: max_amount_in_ref_market = trading_api.get_current_portfolio_value(self.exchange_manager) * \ max_amount_ratio try: max_theoretical_amount = max_amount_in_ref_market / trading_api.get_current_crypto_currency_value( self.exchange_manager, currency) return min(max_theoretical_amount, quantity) except KeyError: self.logger.error(f"Missing price information in reference market for {currency}. Skipping buy order " f"as is it required to ensure the maximum currency percent parameter. " f"Set it to 100 to buy anyway.") return trading_constants.ZERO def _get_split_take_profit_details( self, order_details: list[OrderDetails], total_quantity: decimal.Decimal, symbol_market ): prices = [order_detail.price for order_detail in order_details] amount_ratio_per_order = [ order_detail.quantity for order_detail in order_details if order_detail.quantity is not None ] quantities, prices = trading_personal_data.get_valid_split_orders( total_quantity, prices, symbol_market, amount_ratio_per_order=amount_ratio_per_order ) return [ OrderDetails(price, quantity) for quantity, price in zip(quantities, prices) ] async def _create_order( self, current_order, use_take_profit_orders, take_profits_details: list[OrderDetails], use_stop_loss_orders, stop_loss_details: list[OrderDetails], symbol_market, tag, trailing_profile_type: typing.Optional[trading_personal_data.TrailingProfileTypes], active_order_swap_strategy: trading_personal_data.ActiveOrderSwapStrategy, dependencies: typing.Optional[signals.SignalDependencies], ): params = {} chained_orders = [] is_long = current_order.side is trading_enums.TradeOrderSide.BUY exit_side = trading_enums.TradeOrderSide.SELL if is_long else trading_enums.TradeOrderSide.BUY # tag chained orders as reduce_only when trading futures reduce_only_chained_orders = self.exchange_manager.is_future if use_stop_loss_orders: if len(stop_loss_details) > 1: self.logger.error(f"Multiple stop loss orders is not supported.") stop_price = trading_personal_data.decimal_adapt_price( symbol_market, current_order.origin_price * ( trading_constants.ONE + (self.TARGET_PROFIT_STOP_LOSS * (-1 if is_long else 1)) ) ) if (not stop_loss_details or stop_loss_details[0].price.is_nan()) else stop_loss_details[0].price param_update, chained_order = await self.register_chained_order( current_order, stop_price, trading_enums.TraderOrderType.STOP_LOSS, exit_side, tag=tag, reduce_only=reduce_only_chained_orders ) params.update(param_update) chained_orders.append(chained_order) if use_take_profit_orders: if take_profits_details: local_take_profits_details = self._get_split_take_profit_details( take_profits_details, current_order.origin_quantity, symbol_market ) else: local_take_profits_details = [ OrderDetails(decimal.Decimal("nan"), current_order.origin_quantity) ] for index, take_profits_detail in enumerate(local_take_profits_details): is_last = index == len(local_take_profits_details) - 1 take_profit_price = trading_personal_data.decimal_adapt_price( symbol_market, current_order.origin_price * ( trading_constants.ONE + (self.TARGET_PROFIT_TAKE_PROFIT * (1 if is_long else -1)) ) ) if take_profits_detail.price.is_nan() else take_profits_detail.price order_type = self.exchange_manager.trader.get_take_profit_order_type( current_order, trading_enums.TraderOrderType.SELL_LIMIT if exit_side is trading_enums.TradeOrderSide.SELL else trading_enums.TraderOrderType.BUY_LIMIT ) param_update, chained_order = await self.register_chained_order( current_order, take_profit_price, order_type, exit_side, quantity=take_profits_detail.quantity, tag=tag, reduce_only=reduce_only_chained_orders, # only the last order is to take trigger fees into account update_with_triggering_order_fees=is_last and not self.exchange_manager.is_future ) params.update(param_update) chained_orders.append(chained_order) stop_orders = [o for o in chained_orders if trading_personal_data.is_stop_order(o.order_type)] tp_orders = [o for o in chained_orders if not trading_personal_data.is_stop_order(o.order_type)] if stop_orders and tp_orders: if len(stop_orders) == len(tp_orders): group_type = trading_personal_data.OneCancelsTheOtherOrderGroup elif trailing_profile_type == trading_personal_data.TrailingProfileTypes.FILLED_TAKE_PROFIT: group_type = trading_personal_data.TrailingOnFilledTPBalancedOrderGroup entry_price = current_order.origin_price for stop_order in stop_orders: # register trailing profile in stop orders stop_order.trailing_profile = trading_personal_data.create_filled_take_profit_trailing_profile( entry_price, tp_orders ) else: group_type = trading_personal_data.BalancedTakeProfitAndStopOrderGroup oco_group = self.exchange_manager.exchange_personal_data.orders_manager.create_group( group_type, active_order_swap_strategy=active_order_swap_strategy ) for order in chained_orders: order.add_to_order_group(oco_group) # in futures, inactive orders are not necessary if self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future: await oco_group.active_order_swap_strategy.apply_inactive_orders(chained_orders) return await self.trading_mode.create_order( current_order, params=params or None, dependencies=dependencies ) async def create_new_orders(self, symbol, final_note, state, **kwargs): try: if final_note.is_nan(): return [] except AttributeError: final_note = decimal.Decimal(str(final_note)) if final_note.is_nan(): return [] data = kwargs.get(self.CREATE_ORDER_DATA_PARAM, {}) dependencies = kwargs.get(self.CREATE_ORDER_DEPENDENCIES_PARAM, None) user_price = data.get(self.PRICE_KEY, trading_constants.ZERO) user_volume = data.get(self.VOLUME_KEY, trading_constants.ZERO) user_reduce_only = data.get(self.REDUCE_ONLY_KEY, False) if self.exchange_manager.is_future else None tag = data.get(self.TAG_KEY, None) exchange_creation_params = data.get(self.ORDER_EXCHANGE_CREATION_PARAMS, {}) current_order = None orders_should_have_been_created = False timeout = kwargs.pop("timeout", trading_constants.ORDER_DATA_FETCHING_TIMEOUT) ctx = script_keywords.get_base_context(self.trading_mode, symbol) try: current_symbol_holding, current_market_holding, market_quantity, price, symbol_market = \ await trading_personal_data.get_pre_order_data(self.exchange_manager, symbol=symbol, timeout=timeout) self.logger.debug( f"Order creation inputs: " f"current_symbol_holding: {current_symbol_holding}, " f"current_market_holding: {current_market_holding}, " f"market_quantity: {market_quantity}, " f"price: {price}." ) max_buy_size = market_quantity max_sell_size = current_symbol_holding spot_increasing_position = state in (trading_enums.EvaluatorStates.VERY_LONG.value, trading_enums.EvaluatorStates.LONG.value) if self.exchange_manager.is_future: self.trading_mode.ensure_supported(symbol) # on futures, current_symbol_holding = current_market_holding = market_quantity max_buy_size, buy_increasing_position = trading_personal_data.get_futures_max_order_size( self.exchange_manager, symbol, trading_enums.TradeOrderSide.BUY, price, False, current_symbol_holding, market_quantity ) max_sell_size, sell_increasing_position = trading_personal_data.get_futures_max_order_size( self.exchange_manager, symbol, trading_enums.TradeOrderSide.SELL, price, False, current_symbol_holding, market_quantity ) # take the right value depending on if we are in a buy or sell condition increasing_position = buy_increasing_position if spot_increasing_position else sell_increasing_position else: increasing_position = spot_increasing_position base = symbol_util.parse_symbol(symbol).base created_orders = [] # use stop loss when reducing the position and stop are enabled or when the user explicitly asks for one user_take_profit_price = trading_personal_data.decimal_adapt_price( symbol_market, data.get(self.TAKE_PROFIT_PRICE_KEY, decimal.Decimal(math.nan)) ) additional_user_take_profit_prices = [ trading_personal_data.decimal_adapt_price( symbol_market, price ) for price in (data.get(self.ADDITIONAL_TAKE_PROFIT_PRICES_KEY) or []) ] additional_user_take_profit_volume_ratios = ( list(data.get(self.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY) or []) ) if additional_user_take_profit_volume_ratios: expected_volumes = 0 if (not user_take_profit_price.is_nan()) and additional_user_take_profit_prices: expected_volumes = len(additional_user_take_profit_prices) + 1 elif additional_user_take_profit_prices: expected_volumes = len(additional_user_take_profit_prices) if expected_volumes and len(additional_user_take_profit_volume_ratios) != expected_volumes: raise ValueError( f"{self.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY} must have a size" f" of {expected_volumes}. " f"{len(data[self.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY])=} " f"{len(data[self.ADDITIONAL_TAKE_PROFIT_PRICES_KEY])=}" ) user_stop_price = trading_personal_data.decimal_adapt_price( symbol_market, data.get(self.STOP_PRICE_KEY, decimal.Decimal(math.nan)) ) create_stop_only = data.get(self.STOP_ONLY, False) if create_stop_only and (not user_stop_price or user_stop_price.is_nan()): self.logger.error("Stop price is required to create a stop order") return [] trailing_profile_type = trading_personal_data.TrailingProfileTypes( data[self.TRAILING_PROFILE] ) if data.get(self.TRAILING_PROFILE) else None cancel_policy = trading_personal_data.create_cancel_policy( data.get(self.CANCEL_POLICY), data.get(self.CANCEL_POLICY_PARAMS) ) if data.get(self.CANCEL_POLICY) else None is_reducing_position = not increasing_position if self.USE_TARGET_PROFIT_MODE: if is_reducing_position: self.logger.debug("Ignored reducing position signal as Target Profit Mode is enabled. " "Positions are reduced from chained orders that are created at entry time.") return [] elif not self.TARGET_PROFIT_ENABLE_POSITION_INCREASE: if self.exchange_manager.is_future: current_position = self.exchange_manager.exchange_personal_data.positions_manager\ .get_symbol_position( symbol, trading_enums.PositionSide.BOTH ) if not current_position.is_idle(): self.logger.debug( f"Ignored increasing position signal on {symbol} as Mode 'Enable futures " f"position increase' is disabled." ) return [] use_stop_orders = is_reducing_position and (self.USE_STOP_ORDERS or not user_stop_price.is_nan()) # use stop loss when increasing the position and the user explicitly asks for one use_chained_take_profit_orders = increasing_position and ( (not user_take_profit_price.is_nan() or additional_user_take_profit_prices) or self.USE_TARGET_PROFIT_MODE ) use_chained_stop_loss_orders = increasing_position and ( not user_stop_price.is_nan() or (self.USE_TARGET_PROFIT_MODE and self.USE_STOP_ORDERS) ) stop_loss_order_details = take_profit_order_details = [] if use_chained_take_profit_orders: take_profit_order_details = [] if user_take_profit_price.is_nan() else [ OrderDetails( user_take_profit_price, additional_user_take_profit_volume_ratios[0] if additional_user_take_profit_volume_ratios else None ) ] used_first_volume = not user_take_profit_price.is_nan() take_profit_order_details += [ OrderDetails( price, additional_user_take_profit_volume_ratios[index + (1 if used_first_volume else 0)] if additional_user_take_profit_volume_ratios else None ) for index, price in enumerate(additional_user_take_profit_prices) ] if use_chained_stop_loss_orders: stop_loss_order_details = [OrderDetails(user_stop_price, None)] active_order_swap_strategy = data.get(self.ACTIVE_ORDER_SWAP_STRATEGY) or ( trading_personal_data.StopFirstActiveOrderSwapStrategy(data.get( self.ACTIVE_ORDER_SWAP_TIMEOUT, trading_constants.ACTIVE_ORDER_STRATEGY_SWAP_TIMEOUT )) ) if state == trading_enums.EvaluatorStates.VERY_SHORT.value and not self.DISABLE_SELL_ORDERS: quantity = user_volume \ or await self._get_market_quantity_from_risk( ctx, final_note, max_sell_size, base, True, increasing_position ) quantity = trading_personal_data.decimal_add_dusts_to_quantity_if_necessary(quantity, price, symbol_market, max_sell_size) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, price, symbol_market): orders_should_have_been_created = True current_order = trading_personal_data.create_order_instance( trader=self.trader, order_type=trading_enums.TraderOrderType.SELL_MARKET, symbol=symbol, current_price=order_price, quantity=order_quantity, price=order_price, reduce_only=user_reduce_only, exchange_creation_params=exchange_creation_params, tag=tag, cancel_policy=cancel_policy, ) if current_order := await self._create_order( current_order, use_chained_take_profit_orders, take_profit_order_details, use_chained_stop_loss_orders, stop_loss_order_details, symbol_market, tag, trailing_profile_type, active_order_swap_strategy, dependencies ): created_orders.append(current_order) elif state == trading_enums.EvaluatorStates.SHORT.value and not self.DISABLE_SELL_ORDERS: quantity = user_volume or await self._get_limit_quantity_from_risk( ctx, final_note, max_sell_size, base, True, increasing_position ) quantity = trading_personal_data.decimal_add_dusts_to_quantity_if_necessary(quantity, price, symbol_market, max_sell_size) limit_price = user_stop_price if create_stop_only else trading_personal_data.decimal_adapt_price( symbol_market, user_price or (price * self._get_limit_price_from_risk(final_note)) ) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, limit_price, symbol_market ): orders_should_have_been_created = True current_stop_order = None current_limit_order = trading_personal_data.create_order_instance( trader=self.trader, order_type=trading_enums.TraderOrderType.SELL_LIMIT, symbol=symbol, current_price=price, quantity=order_quantity, price=order_price, reduce_only=user_reduce_only, exchange_creation_params=exchange_creation_params, tag=tag, cancel_policy=cancel_policy, ) if create_stop_only or use_stop_orders: oco_group = None if not create_stop_only: oco_group = self.exchange_manager.exchange_personal_data.orders_manager.create_group( trading_personal_data.OneCancelsTheOtherOrderGroup, active_order_swap_strategy=active_order_swap_strategy ) current_limit_order.add_to_order_group(oco_group) stop_price = trading_personal_data.decimal_adapt_price( symbol_market, price * self._get_stop_price_from_risk(True) ) if user_stop_price.is_nan() else user_stop_price current_stop_order = trading_personal_data.create_order_instance( trader=self.trader, order_type=trading_enums.TraderOrderType.STOP_LOSS, symbol=symbol, current_price=price, quantity=order_quantity, price=stop_price, side=trading_enums.TradeOrderSide.SELL, reduce_only=True, group=oco_group, exchange_creation_params=exchange_creation_params, tag=tag, cancel_policy=cancel_policy if create_stop_only else None, ) # in futures, inactive orders are not necessary if ( oco_group and self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future ): await oco_group.active_order_swap_strategy.apply_inactive_orders( [current_limit_order, current_stop_order] ) # now create orders on exchange created_limit = None if not create_stop_only: created_limit = await self._create_order( current_limit_order, use_chained_take_profit_orders, take_profit_order_details, use_chained_stop_loss_orders, stop_loss_order_details, symbol_market, tag, trailing_profile_type, active_order_swap_strategy, dependencies ) created_orders.append(created_limit) if current_stop_order is not None and (create_stop_only or ( created_limit is not None and created_limit.is_open() )): created_stop = await self.trading_mode.create_order( current_stop_order, dependencies=dependencies ) if create_stop_only: created_orders.append(created_stop) elif state == trading_enums.EvaluatorStates.NEUTRAL.value: return [] elif state == trading_enums.EvaluatorStates.LONG.value and not self.DISABLE_BUY_ORDERS: quantity = await self._get_limit_quantity_from_risk( ctx, final_note, max_buy_size, base, False, increasing_position ) if user_volume == 0 else user_volume limit_price = user_stop_price if create_stop_only else trading_personal_data.decimal_adapt_price( symbol_market, user_price or (price * self._get_limit_price_from_risk(final_note)) ) quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, symbol, trading_enums.TraderOrderType.BUY_LIMIT, quantity, limit_price, trading_enums.TradeOrderSide.BUY ) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, limit_price, symbol_market ): orders_should_have_been_created = True current_stop_order = None current_limit_order = trading_personal_data.create_order_instance( trader=self.trader, order_type=trading_enums.TraderOrderType.BUY_LIMIT, symbol=symbol, current_price=price, quantity=order_quantity, price=order_price, reduce_only=user_reduce_only, exchange_creation_params=exchange_creation_params, tag=tag, cancel_policy=cancel_policy, ) if create_stop_only or use_stop_orders: oco_group = None if not create_stop_only: oco_group = self.exchange_manager.exchange_personal_data.orders_manager.create_group( trading_personal_data.OneCancelsTheOtherOrderGroup, active_order_swap_strategy=active_order_swap_strategy ) current_limit_order.add_to_order_group(oco_group) stop_price = trading_personal_data.decimal_adapt_price( symbol_market, price * self._get_stop_price_from_risk(False) ) if user_stop_price.is_nan() else user_stop_price current_stop_order = trading_personal_data.create_order_instance( trader=self.trader, order_type=trading_enums.TraderOrderType.STOP_LOSS, symbol=symbol, current_price=price, quantity=order_quantity, price=stop_price, side=trading_enums.TradeOrderSide.BUY, reduce_only=True, group=oco_group, exchange_creation_params=exchange_creation_params, tag=tag, cancel_policy=cancel_policy if create_stop_only else None, ) # in futures, inactive orders are not necessary if ( oco_group and self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future ): await oco_group.active_order_swap_strategy.apply_inactive_orders( [current_limit_order, current_stop_order] ) # now create orders on exchange created_limit = None if not create_stop_only: created_limit = await self._create_order( current_limit_order, use_chained_take_profit_orders, take_profit_order_details, use_chained_stop_loss_orders, stop_loss_order_details, symbol_market, tag, trailing_profile_type, active_order_swap_strategy, dependencies ) created_orders.append(created_limit) if current_stop_order is not None and (create_stop_only or ( created_limit is not None and created_limit.is_open() )): created_stop = await self.trading_mode.create_order( current_stop_order, dependencies=dependencies ) if create_stop_only: created_orders.append(created_stop) elif state == trading_enums.EvaluatorStates.VERY_LONG.value and not self.DISABLE_BUY_ORDERS: quantity = await self._get_market_quantity_from_risk( ctx, final_note, max_buy_size, base, False, increasing_position ) \ if user_volume == 0 else user_volume quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, symbol, trading_enums.TraderOrderType.BUY_MARKET, quantity, price, trading_enums.TradeOrderSide.BUY ) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, price, symbol_market ): orders_should_have_been_created = True current_order = trading_personal_data.create_order_instance( trader=self.trader, order_type=trading_enums.TraderOrderType.BUY_MARKET, symbol=symbol, current_price=order_price, quantity=order_quantity, price=order_price, reduce_only=user_reduce_only, exchange_creation_params=exchange_creation_params, tag=tag, cancel_policy=cancel_policy, ) if current_order := await self._create_order( current_order, use_chained_take_profit_orders, take_profit_order_details, use_chained_stop_loss_orders, stop_loss_order_details, symbol_market, tag, trailing_profile_type, active_order_swap_strategy, dependencies ): created_orders.append(current_order) if created_orders: return created_orders if orders_should_have_been_created: raise trading_errors.OrderCreationError() raise trading_errors.MissingMinimalExchangeTradeVolume() except ( trading_errors.MissingFunds, trading_errors.MissingMinimalExchangeTradeVolume, trading_errors.OrderCreationError, trading_errors.InvalidPositionSide, trading_errors.UnsupportedContractConfigurationError, trading_errors.InvalidCancelPolicyError ): raise except asyncio.TimeoutError as e: self.logger.error(f"Impossible to create order for {symbol} on {self.exchange_manager.exchange_name}: {e} " f"and is necessary to compute the order details.") return [] except Exception as e: self.logger.exception(e, True, f"Failed to create order : {e}.") return [] class DailyTradingModeProducer(trading_modes.AbstractTradingModeProducer): def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) self.state = None # If final_eval not is < X_THRESHOLD --> state = X self.VERY_LONG_THRESHOLD = decimal.Decimal("-0.85") self.LONG_THRESHOLD = decimal.Decimal("-0.25") self.NEUTRAL_THRESHOLD = decimal.Decimal("0.25") self.SHORT_THRESHOLD = decimal.Decimal("0.85") self.RISK_THRESHOLD = decimal.Decimal("0.2") async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() await super().stop() async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): strategies_analysis_note_counter = 0 evaluation = commons_constants.INIT_EVAL_NOTE # Strategies analysis for evaluated_strategy_node in matrix.get_tentacles_value_nodes( matrix_id, matrix.get_tentacle_nodes(matrix_id, exchange_name=self.exchange_name, tentacle_type=evaluators_enums.EvaluatorMatrixTypes.STRATEGIES.value), cryptocurrency=cryptocurrency, symbol=symbol): if evaluators_util.check_valid_eval_note(evaluators_api.get_value(evaluated_strategy_node), evaluators_api.get_type(evaluated_strategy_node), evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE): evaluation += evaluators_api.get_value( evaluated_strategy_node ) strategies_analysis_note_counter += 1 if strategies_analysis_note_counter > 0: self.final_eval = decimal.Decimal(str(evaluation / strategies_analysis_note_counter)) await self.create_state(cryptocurrency=cryptocurrency, symbol=symbol) def _get_delta_risk(self): return self.RISK_THRESHOLD * self.exchange_manager.trader.risk async def create_state(self, cryptocurrency: str, symbol: str): if self.final_eval.is_nan(): # discard NaN case as it is not usable await self._set_state(cryptocurrency=cryptocurrency, symbol=symbol, new_state=trading_enums.EvaluatorStates.NEUTRAL) return delta_risk = self._get_delta_risk() if self.final_eval < self.VERY_LONG_THRESHOLD + delta_risk: await self._set_state(cryptocurrency=cryptocurrency, symbol=symbol, new_state=trading_enums.EvaluatorStates.VERY_LONG) elif self.final_eval < self.LONG_THRESHOLD + delta_risk: await self._set_state(cryptocurrency=cryptocurrency, symbol=symbol, new_state=trading_enums.EvaluatorStates.LONG) elif self.final_eval < self.NEUTRAL_THRESHOLD - delta_risk: await self._set_state(cryptocurrency=cryptocurrency, symbol=symbol, new_state=trading_enums.EvaluatorStates.NEUTRAL) elif self.final_eval < self.SHORT_THRESHOLD - delta_risk: await self._set_state(cryptocurrency=cryptocurrency, symbol=symbol, new_state=trading_enums.EvaluatorStates.SHORT) else: await self._set_state(cryptocurrency=cryptocurrency, symbol=symbol, new_state=trading_enums.EvaluatorStates.VERY_SHORT) @classmethod def get_should_cancel_loaded_orders(cls): return True async def _set_state(self, cryptocurrency: str, symbol: str, new_state): if new_state != self.state: self.state = new_state self.logger.info(f"[{symbol}] new state: {self.state.name}") # if new state is not neutral --> cancel orders and create new else keep orders if new_state is not trading_enums.EvaluatorStates.NEUTRAL: _, dependencies = await self.apply_cancel_policies() if self.trading_mode.consumers: if self.trading_mode.consumers[0].USE_TARGET_PROFIT_MODE: new_dependencies = await self._cancel_position_opening_orders(symbol) else: # cancel open orders when not on target profit mode _, new_dependencies = await self.cancel_symbol_open_orders(symbol) if new_dependencies: if dependencies: dependencies.extend(new_dependencies) else: dependencies = new_dependencies # call orders creation from consumers await self.submit_trading_evaluation(cryptocurrency=cryptocurrency, symbol=symbol, time_frame=None, final_note=self.final_eval, state=self.state, dependencies=dependencies) # send_notification if not self.exchange_manager.is_backtesting: await self._send_alert_notification(symbol, new_state) async def _cancel_position_opening_orders(self, symbol) -> signals.SignalDependencies: dependencies = signals.SignalDependencies() if self.exchange_manager.trader.is_enabled: for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol=symbol): if ( not (order.is_cancelled() or order.is_closed()) # orders with chained orders and no "triggered by" are "position opening" and order.chained_orders and order.triggered_by is None ): try: is_cancelled, dependency = await self.trading_mode.cancel_order(order) if is_cancelled: dependencies.extend(dependency) except trading_errors.UnexpectedExchangeSideOrderStateError as err: self.logger.warning(f"Skipped order cancel: {err}, order: {order}") return dependencies async def _send_alert_notification(self, symbol, new_state): try: import octobot_services.api as services_api import octobot_services.enums as services_enum title = f"OCTOBOT ALERT : #{symbol}" alert_content, alert_content_markdown = pretty_printer.cryptocurrency_alert( new_state, self.final_eval) await services_api.send_notification(services_api.create_notification(alert_content, title=title, markdown_text=alert_content_markdown, category=services_enum.NotificationCategory.PRICE_ALERTS)) except ImportError as e: self.logger.exception(e, True, f"Impossible to send notification: {e}") ================================================ FILE: Trading/Mode/daily_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["DailyTradingMode"], "tentacles-requirements": ["mixed_strategies_evaluator"] } ================================================ FILE: Trading/Mode/daily_trading_mode/resources/DailyTradingMode.md ================================================ The DailyTradingMode will consider every compatible strategy and evaluator and average their evaluation to create each update. It will create orders when its state changes to a state that is different from the previous one and that is not NEUTRAL. A LONG state will trigger a buy order. A SHORT state will trigger a sell order.
To know more, checkout the full Daily trading mode guide. ### Default mode On Default mode, the DailyTradingMode will cancel previously created open orders and create new ones according to its new state. In this mode, both buy and sell orders will be exclusively created upon strategy and evaluator signals. ### Target profits mode On Target profits mode, the DailyTradingMode will only listen for LONG signals when trading spot and position-increasing signals when trading futures, which means both SHORT and LONG. When such a signal is received, it will create an entry order that will be followed by a take profit (and possibly a stop-loss) when filled. In this mode, only entry signals are defined by your strategy and evaluator configuration as take profit and stop loss targets are defined in the Target profits mode configuration. *Using the DailyTradingMode in Target profits mode is compatible with PNL history.* ### About futures trading The **Target profits** mode is more adapted to futures trading as it creates take profits and stop losses (when enabled) to close created positions. ================================================ FILE: Trading/Mode/daily_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/daily_trading_mode/tests/test_daily_trading_mode.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import octobot_commons.tests.test_config as test_config import octobot_tentacles_manager.api as tentacles_manager_api import tests.test_utils.memory_check_util as memory_check_util import tentacles.Evaluator.Strategies as Strategies import tentacles.Evaluator.TA as Evaluator import tentacles.Trading.Mode as Mode # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_run_independent_backtestings_with_memory_check(): tentacles_setup_config = tentacles_manager_api.create_tentacles_setup_config_with_tentacles(Mode.DailyTradingMode, Strategies.SimpleStrategyEvaluator, Evaluator.RSIMomentumEvaluator, Evaluator.DoubleMovingAverageTrendEvaluator) await memory_check_util.run_independent_backtestings_with_memory_check(test_config.load_test_config(), tentacles_setup_config) ================================================ FILE: Trading/Mode/daily_trading_mode/tests/test_daily_trading_mode_consumer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import math import mock import pytest import os.path import copy import pytest_asyncio import async_channel.util as channel_util import octobot_backtesting.api as backtesting_api import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.constants as commons_constants import octobot_commons.tests.test_config as test_config import octobot_trading.constants as trading_constants import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchange_data as exchange_data import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot_trading.exchanges as exchanges import octobot_trading.modes.script_keywords as script_keywords import octobot_trading.personal_data as trading_personal_data import tentacles.Trading.Mode as Mode import tests.unit_tests.trading_modes_tests.trading_mode_test_toolkit as trading_mode_test_toolkit import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges import octobot_tentacles_manager.api as tentacles_manager_api # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def tools(): tentacles_manager_api.reload_tentacle_info() exchange_manager = None try: symbol = "BTC/USDT" config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "SUB"] = 0.000000000000000000005 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "BNB"] = 0.000000000000000000005 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.DailyTradingMode(config, exchange_manager) await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) consumer = mode.get_trading_mode_consumers()[0] consumer.MAX_CURRENCY_RATIO = 1 # set BTC/USDT price at 7009.194999999998 USDT last_btc_price = 7009.194999999998 trading_api.force_set_mark_price(exchange_manager, symbol, last_btc_price) yield exchange_manager, trader, symbol, consumer, decimal.Decimal(str(last_btc_price)) finally: if exchange_manager: try: await _stop(exchange_manager) except Exception as err: print(f"error when stopping exchange manager: {err}") @pytest_asyncio.fixture async def future_tools(): tentacles_manager_api.reload_tentacle_info() exchange_manager = None try: symbol = "BTC/USDT:USDT" config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "SUB"] = 0.000000000000000000005 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "BNB"] = 0.000000000000000000005 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_spot_only = False exchange_manager.is_future = True exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) contract = exchange_data.FutureContract( pair=symbol, margin_type=trading_enums.MarginType.ISOLATED, contract_type=trading_enums.FutureContractType.LINEAR_PERPETUAL, current_leverage=trading_constants.ONE, maximum_leverage=trading_constants.ONE_HUNDRED ) exchange_manager.exchange.set_pair_future_contract(symbol, contract) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.DailyTradingMode(config, exchange_manager) await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) consumer = mode.get_trading_mode_consumers()[0] consumer.MAX_CURRENCY_RATIO = 1 # set BTC/USDT:USDT price at 7009.194999999998 USDT last_btc_price = 7009.194999999998 trading_api.force_set_mark_price(exchange_manager, symbol, last_btc_price) yield exchange_manager, trader, symbol, consumer, decimal.Decimal(str(last_btc_price)) finally: if exchange_manager: try: await _stop(exchange_manager) except Exception as err: print(f"error when stopping exchange manager: {err}") async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() # let updaters gracefully shutdown await asyncio_tools.wait_asyncio_next_cycle() async def test_valid_create_new_orders_no_ref_market_as_quote(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # change reference market to USDT exchange_manager.exchange_personal_data.portfolio_manager.reference_market = "USDT" exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[ symbol] = last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(last_btc_price * 10 + 1000)) market_status = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) # portfolio: "BTC": 10 "USD": 1000 # order from neutral state _origin_decimal_adapt_order_quantity_because_fees = trading_personal_data.decimal_adapt_order_quantity_because_fees def _decimal_adapt_order_quantity_because_fees( exchange_manager, symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, side: trading_enums.TradeOrderSide ): return quantity with mock.patch.object( trading_personal_data, "decimal_adapt_order_quantity_because_fees", mock.Mock(side_effect=_decimal_adapt_order_quantity_because_fees) ) as decimal_adapt_order_quantity_because_fees_mock: assert await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(0.5)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, trading_constants.ZERO, trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.5)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] # neutral state decimal_adapt_order_quantity_because_fees_mock.assert_not_called() # valid sell limit order (price adapted) orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.65)), trading_enums.EvaluatorStates.SHORT.value) # short state decimal_adapt_order_quantity_because_fees_mock.assert_not_called() assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.SellLimitOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == decimal.Decimal(str(7062.64011187)) assert order.created_last_price == last_btc_price assert order.order_type == trading_enums.TraderOrderType.SELL_LIMIT assert order.side == trading_enums.TradeOrderSide.SELL assert order.status == trading_enums.OrderStatus.OPEN assert order.exchange_manager == exchange_manager assert order.trader == trader assert order.fee is None assert order.filled_price == trading_constants.ZERO assert order.origin_quantity == decimal.Decimal(str(7.6)) assert order.filled_quantity == trading_constants.ZERO assert order.simulated is True assert order.chained_orders == [] assert isinstance(order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) trading_mode_test_toolkit.check_order_limits(order, market_status) trading_mode_test_toolkit.check_oco_order_group(order, trading_enums.TraderOrderType.STOP_LOSS, decimal.Decimal(str(6658.73524999)), market_status) # valid buy limit order with (price and quantity adapted) orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.65)), trading_enums.EvaluatorStates.LONG.value) assert len(orders) == 1 order = orders[0] # long state adapted_args = list(decimal_adapt_order_quantity_because_fees_mock.mock_calls[0].args) adapted_args[3] = trading_personal_data.decimal_adapt_quantity(market_status, adapted_args[3]) adapted_args[4] = trading_personal_data.decimal_adapt_price(market_status, adapted_args[4]) assert adapted_args == [ exchange_manager, order.symbol, trading_enums.TraderOrderType.BUY_LIMIT, order.origin_quantity, order.origin_price, trading_enums.TradeOrderSide.BUY, ] decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert isinstance(order, trading_personal_data.BuyLimitOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == decimal.Decimal(str(6955.74988812)) assert order.created_last_price == last_btc_price assert order.order_type == trading_enums.TraderOrderType.BUY_LIMIT assert order.side == trading_enums.TradeOrderSide.BUY assert order.status == trading_enums.OrderStatus.OPEN assert order.exchange_manager == exchange_manager assert order.trader == trader assert order.fee is None assert order.filled_price == trading_constants.ZERO assert order.origin_quantity == decimal.Decimal(str(0.12554936)) assert order.filled_quantity == trading_constants.ZERO assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) truncated_last_price = trading_personal_data.decimal_trunc_with_n_decimal_digits(last_btc_price, 8) # valid buy market order with (price and quantity adapted) using user_given quantity (which is adapted as well) orders = await consumer.create_new_orders( symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.VERY_LONG.value, data={ consumer.VOLUME_KEY: decimal.Decimal('0.0123') } ) assert len(orders) == 1 order = orders[0] assert order.origin_quantity == decimal.Decimal('0.0123') # very long state adapted_args = list(decimal_adapt_order_quantity_because_fees_mock.mock_calls[0].args) adapted_args[3] = trading_personal_data.decimal_adapt_quantity(market_status, adapted_args[3]) adapted_args[4] = trading_personal_data.decimal_adapt_price(market_status, adapted_args[4]) assert adapted_args == [ exchange_manager, order.symbol, trading_enums.TraderOrderType.BUY_MARKET, order.origin_quantity, order.origin_price, trading_enums.TradeOrderSide.BUY, ] decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert isinstance(order, trading_personal_data.BuyMarketOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == truncated_last_price assert order.created_last_price == truncated_last_price assert order.order_type == trading_enums.TraderOrderType.BUY_MARKET assert order.side == trading_enums.TradeOrderSide.BUY assert order.status == trading_enums.OrderStatus.FILLED # order has been cleared assert order.exchange_manager is None assert order.trader is None assert order.fee assert order.filled_price == decimal.Decimal(str(7009.19499999)) assert order.origin_quantity == decimal.Decimal('0.0123') assert order.filled_quantity == order.origin_quantity assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) # valid buy market order with (price and quantity adapted) orders = await consumer.create_new_orders(symbol, trading_constants.ONE, trading_enums.EvaluatorStates.VERY_SHORT.value) # very short state decimal_adapt_order_quantity_because_fees_mock.assert_not_called() assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.SellMarketOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == truncated_last_price assert order.created_last_price == truncated_last_price assert order.order_type == trading_enums.TraderOrderType.SELL_MARKET assert order.side == trading_enums.TradeOrderSide.SELL assert order.status == trading_enums.OrderStatus.FILLED assert order.exchange_manager is None assert order.trader is None assert order.fee assert order.filled_price == decimal.Decimal(str(7009.19499999)) assert order.origin_quantity == decimal.Decimal('2.4122877') assert order.filled_quantity == order.origin_quantity assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) async def test_valid_create_new_orders_ref_market_as_quote(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[ symbol] = last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) # portfolio: "BTC": 10 "USD": 1000 # order from neutral state assert await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(0.5)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(0)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.5)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] assert await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.NEUTRAL.value) == [] # valid sell limit order (price adapted) orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.65)), trading_enums.EvaluatorStates.SHORT.value) assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.SellLimitOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == decimal.Decimal(str(7062.64011187)) assert order.created_last_price == last_btc_price assert order.order_type == trading_enums.TraderOrderType.SELL_LIMIT assert order.side == trading_enums.TradeOrderSide.SELL assert order.status == trading_enums.OrderStatus.OPEN assert order.exchange_manager == exchange_manager assert order.trader == trader assert order.fee is None assert order.filled_price == trading_constants.ZERO assert order.origin_quantity == decimal.Decimal(str(4.4)) assert order.filled_quantity == trading_constants.ZERO assert order.simulated is True assert isinstance(order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) market_status = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) trading_mode_test_toolkit.check_order_limits(order, market_status) trading_mode_test_toolkit.check_oco_order_group(order, trading_enums.TraderOrderType.STOP_LOSS, decimal.Decimal(str(6658.73524999)), market_status) # valid buy limit order with (price and quantity adapted) orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.65)), trading_enums.EvaluatorStates.LONG.value) assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.BuyLimitOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == decimal.Decimal(str(6955.74988812)) assert order.created_last_price == last_btc_price assert order.order_type == trading_enums.TraderOrderType.BUY_LIMIT assert order.side == trading_enums.TradeOrderSide.BUY assert order.status == trading_enums.OrderStatus.OPEN assert order.exchange_manager == exchange_manager assert order.trader == trader assert order.fee is None assert order.filled_price == trading_constants.ZERO assert order.origin_quantity == decimal.Decimal(str(0.21685799)) assert order.filled_quantity == trading_constants.ZERO assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) truncated_last_price = trading_personal_data.decimal_trunc_with_n_decimal_digits(last_btc_price, 8) # valid buy market order with (price and quantity adapted) orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.VERY_LONG.value) assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.BuyMarketOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == truncated_last_price assert order.created_last_price == truncated_last_price assert order.order_type == trading_enums.TraderOrderType.BUY_MARKET assert order.side == trading_enums.TradeOrderSide.BUY assert order.status == trading_enums.OrderStatus.FILLED assert order.filled_price == decimal.Decimal(str(7009.19499999)) assert order.origin_quantity == decimal.Decimal(str(0.07013502)) assert order.filled_quantity == order.origin_quantity assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) # valid buy market order with (price and quantity adapted) orders = await consumer.create_new_orders(symbol, trading_constants.ONE, trading_enums.EvaluatorStates.VERY_SHORT.value) assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.SellMarketOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == truncated_last_price assert order.created_last_price == truncated_last_price assert order.order_type == trading_enums.TraderOrderType.SELL_MARKET assert order.side == trading_enums.TradeOrderSide.SELL assert order.status == trading_enums.OrderStatus.FILLED assert order.fee assert order.filled_price == decimal.Decimal(str(7009.19499999)) assert order.origin_quantity == decimal.Decimal(str(4.08244671)) assert order.filled_quantity == order.origin_quantity assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) async def test_invalid_create_new_orders(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # portfolio: "BTC": 10 "USD": 1000 min_trigger_market = "ADA/BNB" # invalid sell order with not trade data import octobot_trading.constants trading_constants.ORDER_DATA_FETCHING_TIMEOUT = 0.1 assert await consumer.create_new_orders(min_trigger_market, decimal.Decimal(str(0.6)), trading_enums.EvaluatorStates.SHORT.value, timeout=1) == [] exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = decimal.Decimal(str(0.000000000000000000005)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = decimal.Decimal(str(2000)) # invalid sell order with not enough currency to sell with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): await consumer.create_new_orders(symbol, decimal.Decimal(str(0.6)), trading_enums.EvaluatorStates.SHORT.value) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available = decimal.Decimal(str(0.000000000000000000005)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").total = decimal.Decimal(str(2000)) # invalid buy order with not enough currency to buy with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.6)), trading_enums.EvaluatorStates.LONG.value) async def test_create_new_orders_with_dusts_included(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = decimal.Decimal(str(0.000015)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = decimal.Decimal(str(0.000015)) # trigger order that should not sell everything but does sell everything because remaining amount # is not sellable orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.6)), trading_enums.EvaluatorStates.VERY_SHORT.value) assert len(orders) == 1 exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = trading_constants.ZERO exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = trading_constants.ZERO test_currency = "NEO" test_pair = f"{test_currency}/BTC" exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio(test_currency).available = decimal.Decimal(str(0.44)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio(test_currency).total = decimal.Decimal(str(0.44)) trading_api.force_set_mark_price(exchange_manager, test_pair, 0.005318) # trigger order that should not sell everything but does sell everything because remaining amount # is not sellable orders = await consumer.create_new_orders(test_pair, decimal.Decimal(str(0.75445456165478)), trading_enums.EvaluatorStates.SHORT.value) assert len(orders) == 1 assert exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio(test_currency).available == trading_constants.ZERO assert exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio(test_currency).total == orders[0].origin_quantity async def test_split_create_new_orders(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # change reference market to get more orders exchange_manager.exchange_personal_data.portfolio_manager.reference_market = "USDT" exchange_manager.exchange_personal_data.portfolio_manager.reference_market = "USDT" market_status = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = decimal.Decimal(str(2000000001)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = decimal.Decimal(str(2000000001)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[ symbol] = last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(last_btc_price * 2000000001 + 1000)) # split orders because order too big and coin price too high orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.6)), trading_enums.EvaluatorStates.SHORT.value) assert len(orders) == 11 adapted_order = orders[0] identical_orders = orders[1:] assert isinstance(adapted_order, trading_personal_data.SellLimitOrder) assert adapted_order.currency == "BTC" assert adapted_order.symbol == "BTC/USDT" assert adapted_order.origin_price == decimal.Decimal(str(7065.26855999)) assert adapted_order.created_last_price == last_btc_price assert adapted_order.order_type == trading_enums.TraderOrderType.SELL_LIMIT assert adapted_order.side == trading_enums.TradeOrderSide.SELL assert adapted_order.status == trading_enums.OrderStatus.OPEN assert adapted_order.exchange_manager == exchange_manager assert adapted_order.trader == trader assert adapted_order.fee is None assert adapted_order.filled_price == trading_constants.ZERO assert adapted_order.origin_quantity == decimal.Decimal(str(64625635.97358073)) assert adapted_order.filled_quantity == trading_constants.ZERO assert adapted_order.simulated is True assert isinstance(adapted_order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) trading_mode_test_toolkit.check_order_limits(adapted_order, market_status) trading_mode_test_toolkit.check_oco_order_group(adapted_order, trading_enums.TraderOrderType.STOP_LOSS, decimal.Decimal(str(6658.73524999)), market_status) for order in identical_orders: assert isinstance(order, trading_personal_data.SellLimitOrder) assert order.currency == adapted_order.currency assert order.symbol == adapted_order.symbol assert order.origin_price == adapted_order.origin_price assert order.created_last_price == adapted_order.created_last_price assert order.order_type == adapted_order.order_type assert order.side == adapted_order.side assert order.status == adapted_order.status assert order.exchange_manager == adapted_order.exchange_manager assert order.trader == adapted_order.trader assert order.fee == adapted_order.fee assert order.filled_price == adapted_order.filled_price assert order.origin_quantity == decimal.Decimal(str(141537436.47664192)) assert order.origin_quantity > adapted_order.origin_quantity assert order.filled_quantity == trading_constants.ZERO assert order.simulated == adapted_order.simulated assert isinstance(order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) trading_mode_test_toolkit.check_order_limits(order, market_status) trading_mode_test_toolkit.check_oco_order_group(order, trading_enums.TraderOrderType.STOP_LOSS, decimal.Decimal(str(6658.73524999)), market_status) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available = decimal.Decimal(str(40000000000)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").total = decimal.Decimal(str(40000000000)) # set btc last price to 6998.55407999 * 0.000001 = 0.00699855408 trading_api.force_set_mark_price(exchange_manager, symbol, float(last_btc_price * decimal.Decimal(str(0.000001)))) # split orders because order too big and too many coins orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.6)), trading_enums.EvaluatorStates.LONG.value) assert len(orders) == 3 adapted_order = orders[0] identical_orders = orders[1:] assert isinstance(adapted_order, trading_personal_data.BuyLimitOrder) assert adapted_order.currency == "BTC" assert adapted_order.symbol == "BTC/USDT" assert adapted_order.origin_price == decimal.Decimal(str(0.00695312)) assert adapted_order.created_last_price == decimal.Decimal(str(0.007009194999999998)) assert adapted_order.order_type == trading_enums.TraderOrderType.BUY_LIMIT assert adapted_order.side == trading_enums.TradeOrderSide.BUY assert adapted_order.status == trading_enums.OrderStatus.OPEN assert adapted_order.exchange_manager == exchange_manager assert adapted_order.trader == trader assert adapted_order.fee is None assert adapted_order.filled_price == trading_constants.ZERO assert adapted_order.origin_quantity == decimal.Decimal("396851564266.65327383") assert adapted_order.filled_quantity == trading_constants.ZERO assert adapted_order.simulated is True trading_mode_test_toolkit.check_order_limits(adapted_order, market_status) for order in identical_orders: assert isinstance(order, trading_personal_data.BuyLimitOrder) assert order.currency == adapted_order.currency assert order.symbol == adapted_order.symbol assert order.origin_price == adapted_order.origin_price assert order.created_last_price == adapted_order.created_last_price assert order.order_type == adapted_order.order_type assert order.side == adapted_order.side assert order.status == adapted_order.status assert order.exchange_manager == adapted_order.exchange_manager assert order.trader == adapted_order.trader assert order.fee == adapted_order.fee assert order.filled_price == adapted_order.filled_price assert order.origin_quantity == decimal.Decimal(str(1000000000000.0)) assert order.origin_quantity > adapted_order.origin_quantity assert order.filled_quantity == trading_constants.ZERO assert order.simulated == adapted_order.simulated trading_mode_test_toolkit.check_order_limits(order, market_status) async def test_valid_create_new_orders_without_stop_order(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # change reference market to get more orders exchange_manager.exchange_personal_data.portfolio_manager.reference_market = "USDT" exchange_manager.exchange_personal_data.portfolio_manager.reference_market = "USDT" exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[ symbol] = last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(last_btc_price * 10 + 1000)) market_status = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) # force no stop orders consumer.USE_STOP_ORDERS = False # valid sell limit order (price adapted) orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.65)), trading_enums.EvaluatorStates.SHORT.value) assert len(orders) == 1 order = orders[0] assert isinstance(order, trading_personal_data.SellLimitOrder) assert order.currency == "BTC" assert order.symbol == "BTC/USDT" assert order.origin_price == decimal.Decimal(str(7062.64011187)) assert order.created_last_price == last_btc_price assert order.order_type == trading_enums.TraderOrderType.SELL_LIMIT assert order.side == trading_enums.TradeOrderSide.SELL assert order.status == trading_enums.OrderStatus.OPEN assert order.exchange_manager == exchange_manager assert order.trader == trader assert order.fee is None assert order.filled_price == trading_constants.ZERO assert order.origin_quantity == decimal.Decimal(str(7.6)) assert order.filled_quantity == trading_constants.ZERO assert order.simulated is True assert order.order_group is None assert order.chained_orders == [] trading_mode_test_toolkit.check_order_limits(order, market_status) def _get_evaluations_gradient(step): nb_steps = 1 / step return [decimal.Decimal(str(i / nb_steps)) for i in range(int(-nb_steps), int(nb_steps + 1), 1)] def _get_states_gradient_with_invald_states(): states = [state.value for state in trading_enums.EvaluatorStates] states += [None, 1, {'toto': 1}, math.nan] return states def _get_irrationnal_numbers(): irrationals = [math.pi, math.sqrt(2), math.sqrt(3), math.sqrt(5), math.sqrt(7), math.sqrt(11), math.sqrt(73), 10 / 3] return [decimal.Decimal(str(1 / i)) for i in irrationals] def _reset_portfolio(exchange_manager): exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = decimal.Decimal(str(10)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = decimal.Decimal(str(10)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available = decimal.Decimal(str(2000)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").total = decimal.Decimal(str(2000)) async def test_create_orders_using_a_lot_of_different_inputs_with_portfolio_reset(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools gradient_step = 0.005 nb_orders = 1 initial_portfolio = copy.copy(exchange_manager.exchange_personal_data.portfolio_manager.portfolio) portfolio_wrapper = exchange_manager.exchange_personal_data.portfolio_manager.portfolio market_status = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) min_trigger_market = "ADA/BNB" trading_api.force_set_mark_price(exchange_manager, min_trigger_market, 0.001) for state in _get_states_gradient_with_invald_states(): for evaluation in _get_evaluations_gradient(gradient_step): _reset_portfolio(exchange_manager) # orders are possible try: orders = await consumer.create_new_orders(symbol, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, nb_orders, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass # orders are impossible try: orders = [] orders = await consumer.create_new_orders(min_trigger_market, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass for evaluation in _get_irrationnal_numbers(): # orders are possible _reset_portfolio(exchange_manager) try: orders = await consumer.create_new_orders(symbol, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, nb_orders, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass # orders are impossible try: orders = [] orders = await consumer.create_new_orders(min_trigger_market, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass _reset_portfolio(exchange_manager) # orders are possible try: orders = await consumer.create_new_orders(symbol, decimal.Decimal("nan"), state) trading_mode_test_toolkit.check_orders(orders, decimal.Decimal("nan"), state, nb_orders, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass # orders are impossible try: orders = [] orders = await consumer.create_new_orders(min_trigger_market, decimal.Decimal("nan"), state) trading_mode_test_toolkit.check_orders(orders, decimal.Decimal("nan"), state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass try: orders = [] # float evaluation orders = await consumer.create_new_orders(min_trigger_market, math.nan, state) trading_mode_test_toolkit.check_orders(orders, math.nan, state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders) except trading_errors.MissingMinimalExchangeTradeVolume: pass async def test_create_order_using_a_lot_of_different_inputs_without_portfolio_reset(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools gradient_step = 0.001 nb_orders = "unknown" initial_portfolio = copy.copy(exchange_manager.exchange_personal_data.portfolio_manager.portfolio) portfolio_wrapper = exchange_manager.exchange_personal_data.portfolio_manager.portfolio market_status = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) min_trigger_market = "ADA/BNB" trading_api.force_set_mark_price(exchange_manager, min_trigger_market, 0.001) _reset_portfolio(exchange_manager) for state in _get_states_gradient_with_invald_states(): for evaluation in _get_evaluations_gradient(gradient_step): # orders are possible try: orders = await consumer.create_new_orders(symbol, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, nb_orders, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders, True) await trading_mode_test_toolkit.fill_orders(orders, trader) except trading_errors.MissingMinimalExchangeTradeVolume: pass # orders are impossible try: orders = [] orders = await consumer.create_new_orders(min_trigger_market, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders, True) await trading_mode_test_toolkit.fill_orders(orders, trader) except trading_errors.MissingMinimalExchangeTradeVolume: pass _reset_portfolio(exchange_manager) for state in _get_states_gradient_with_invald_states(): for evaluation in _get_irrationnal_numbers(): # orders are possible try: orders = await consumer.create_new_orders(symbol, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, nb_orders, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders, True) if any(order for order in orders if order.order_type not in ( trading_enums.TraderOrderType.SELL_MARKET, trading_enums.TraderOrderType.BUY_MARKET)): # no need to fill market orders await trading_mode_test_toolkit.fill_orders(orders, trader) except trading_errors.MissingMinimalExchangeTradeVolume: pass # orders are impossible try: orders = [] orders = await consumer.create_new_orders(min_trigger_market, evaluation, state) trading_mode_test_toolkit.check_orders(orders, evaluation, state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders, True) if any(order for order in orders if order.order_type not in ( trading_enums.TraderOrderType.SELL_MARKET, trading_enums.TraderOrderType.BUY_MARKET)): # no need to fill market orders await trading_mode_test_toolkit.fill_orders(orders, trader) except trading_errors.MissingMinimalExchangeTradeVolume: pass _reset_portfolio(exchange_manager) for state in _get_states_gradient_with_invald_states(): # orders are possible try: orders = await consumer.create_new_orders(symbol, decimal.Decimal("nan"), state) trading_mode_test_toolkit.check_orders(orders, math.nan, state, nb_orders, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders, True) await trading_mode_test_toolkit.fill_orders(orders, trader) except trading_errors.MissingMinimalExchangeTradeVolume: pass # orders are impossible try: orders = [] orders = await consumer.create_new_orders(min_trigger_market, decimal.Decimal("nan"), state) trading_mode_test_toolkit.check_orders(orders, math.nan, state, 0, market_status) trading_mode_test_toolkit.check_portfolio(portfolio_wrapper, initial_portfolio, orders, True) await trading_mode_test_toolkit.fill_orders(orders, trader) except trading_errors.MissingMinimalExchangeTradeVolume: pass async def test_create_multiple_buy_orders_after_fill(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) # force many traded asset not to create all in orders exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.origin_crypto_currencies_values \ = { "a": trading_constants.ZERO, "b": trading_constants.ZERO, "c": trading_constants.ZERO, "d": trading_constants.ZERO, "e": trading_constants.ZERO } await ensure_smaller_orders(consumer, symbol, trader) # with another symbol with 0 quantity when start trading_api.force_set_mark_price(exchange_manager, "ADA/BTC", 0.0000001) await ensure_smaller_orders(consumer, "ADA/BTC", trader) async def ensure_smaller_orders(consumer, symbol, trader): state = trading_enums.EvaluatorStates.VERY_LONG.value # first call: biggest order orders1 = (await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state)) if any(order for order in orders1 if order.order_type not in ( trading_enums.TraderOrderType.SELL_MARKET, trading_enums.TraderOrderType.BUY_MARKET)): # no need to fill market orders await trading_mode_test_toolkit.fill_orders(orders1, trader) state = trading_enums.EvaluatorStates.LONG.value # second call: smaller order (same with very long as with long) orders2 = (await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.6)), state)) assert orders1[0].origin_quantity > orders2[0].origin_quantity if any(order for order in orders2 if order.order_type not in ( trading_enums.TraderOrderType.SELL_MARKET, trading_enums.TraderOrderType.BUY_MARKET)): # no need to fill market orders await trading_mode_test_toolkit.fill_orders(orders2, trader) # third call: even smaller order orders3 = (await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.6)), state)) assert orders2[0].origin_quantity > orders3[0].origin_quantity if any(order for order in orders3 if order.order_type not in ( trading_enums.TraderOrderType.SELL_MARKET, trading_enums.TraderOrderType.BUY_MARKET)): # no need to fill market orders await trading_mode_test_toolkit.fill_orders(orders3, trader) # third call: even-even smaller order orders4 = (await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.6)), state)) assert orders3[0].origin_quantity > orders4[0].origin_quantity if any(order for order in orders4 if order.order_type not in ( trading_enums.TraderOrderType.SELL_MARKET, trading_enums.TraderOrderType.BUY_MARKET)): # no need to fill market orders await trading_mode_test_toolkit.fill_orders(orders4, trader) async def test_create_new_orders_with_cancel_policy(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) # simple buy order data = { consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.CANCEL_POLICY: trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy.__name__, } orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.LONG.value, data=data) buy_order = orders[0] assert isinstance(buy_order, trading_personal_data.BuyLimitOrder) assert isinstance(buy_order.cancel_policy, trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy) assert len(buy_order.chained_orders) == 0 # buy order order with stop data = { consumer.STOP_PRICE_KEY: decimal.Decimal("10"), consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.CANCEL_POLICY: trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy.__name__, } orders_with_stop = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.VERY_LONG.value, data=data) buy_order = orders_with_stop[0] assert isinstance(buy_order, trading_personal_data.BuyMarketOrder) assert isinstance(buy_order.cancel_policy, trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy) assert len(buy_order.chained_orders) == 1 stop_order = buy_order.chained_orders[0] assert stop_order.cancel_policy is None # cancel policy is set on the entry order only assert stop_order.is_open() # simple sell order with invalid policy data = { consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.CANCEL_POLICY: trading_personal_data.ExpirationTimeOrderCancelPolicy.__name__, } with pytest.raises(trading_errors.InvalidCancelPolicyError): orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(1)), trading_enums.EvaluatorStates.SHORT.value, data=data) # simple sell order with valid policy data = { consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.CANCEL_POLICY: trading_personal_data.ExpirationTimeOrderCancelPolicy.__name__, consumer.CANCEL_POLICY_PARAMS: { "expiration_time": 1000.0, }, } orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(1)), trading_enums.EvaluatorStates.SHORT.value, data=data) sell_order = orders[0] assert isinstance(sell_order, trading_personal_data.SellLimitOrder) assert isinstance(sell_order.cancel_policy, trading_personal_data.ExpirationTimeOrderCancelPolicy) assert sell_order.cancel_policy.expiration_time == 1000.0 assert len(sell_order.chained_orders) == 0 async def test_chained_stop_loss_and_take_profit_orders(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) state = trading_enums.EvaluatorStates.VERY_LONG.value # stop loss only data = { consumer.STOP_PRICE_KEY: decimal.Decimal("10"), consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.TAG_KEY: "super" } orders_with_stop = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state, data=data) buy_order = orders_with_stop[0] assert buy_order.cancel_policy is None assert len(buy_order.chained_orders) == 1 assert buy_order.tag == "super" stop_order = buy_order.chained_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, stop_order.quantity_currency) assert stop_order.origin_price == decimal.Decimal("10") # stop has been triggered as signal is triggering a buy market order that is instantly filled assert stop_order.is_waiting_for_chained_trigger is False assert stop_order.associated_entry_ids == [buy_order.order_id] assert stop_order.tag == "super" assert stop_order.reduce_only is False assert stop_order.trailing_profile is None assert stop_order.cancel_policy is None assert stop_order.is_open() state = trading_enums.EvaluatorStates.LONG.value # take profit only data = { consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("100000"), consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [], consumer.VOLUME_KEY: decimal.Decimal("0.01"), } orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state, data=data) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 1 take_profit_order = buy_order.chained_orders[0] assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) assert take_profit_order.origin_price == decimal.Decimal("100000") assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert take_profit_order.trailing_profile is None assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.reduce_only is False # take profit only using ADDITIONAL_TAKE_PROFIT_PRICES_KEY data = { consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [decimal.Decimal("100000")], consumer.VOLUME_KEY: decimal.Decimal("0.01"), } orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state, data=data) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 1 take_profit_order = buy_order.chained_orders[0] assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) assert take_profit_order.origin_price == decimal.Decimal("100000") assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.reduce_only is False assert take_profit_order.trailing_profile is None # stop loss and take profit data = { consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("100012"), consumer.STOP_PRICE_KEY: decimal.Decimal("123"), consumer.VOLUME_KEY: decimal.Decimal("0.01"), } orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.4)), state, data=data) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 2 stop_order = buy_order.chained_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, stop_order.quantity_currency) assert stop_order.origin_price == decimal.Decimal("123") assert stop_order.is_waiting_for_chained_trigger assert stop_order.associated_entry_ids == [buy_order.order_id] assert stop_order.trailing_profile is None assert stop_order.cancel_policy is None assert not take_profit_order.is_open() assert not take_profit_order.is_created() take_profit_order = buy_order.chained_orders[1] assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) assert take_profit_order.origin_price == decimal.Decimal("100012") assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.reduce_only is False assert isinstance(stop_order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) assert take_profit_order.order_group is stop_order.order_group assert take_profit_order.trailing_profile is None assert take_profit_order.cancel_policy is None # stop loss and take profit but decreasing position size: create stop loss and no take profit # (this initial order is a take profit already) state = trading_enums.EvaluatorStates.SHORT.value data = { consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("100012"), consumer.STOP_PRICE_KEY: decimal.Decimal("123"), consumer.VOLUME_KEY: decimal.Decimal("0.01"), } orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.4)), state, data=data) assert len(orders) == 1 sell_limit = orders[0] order_group = sell_limit.order_group stop_loss = exchange_manager.exchange_personal_data.orders_manager.get_order_from_group(order_group.name)[1] assert isinstance(sell_limit, trading_personal_data.SellLimitOrder) assert isinstance(stop_loss, trading_personal_data.StopLossOrder) assert sell_limit.chained_orders == [] assert stop_loss.associated_entry_ids is None assert stop_loss.chained_orders == [] assert stop_loss.reduce_only is True # True as force stop loss assert stop_loss.origin_price == decimal.Decimal("123") assert stop_loss.trailing_profile is None assert stop_loss.cancel_policy is None assert stop_loss.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(sell_limit.fee, stop_loss.quantity_currency) async def test_chained_multiple_take_profit_orders(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) state = trading_enums.EvaluatorStates.LONG.value # 1 take profit and 2 additional (3 in total) data = { consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("100000"), consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [decimal.Decimal("110000"), decimal.Decimal("120000")], consumer.VOLUME_KEY: decimal.Decimal("0.01"), } orders_with_tps = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state, data=data) buy_order = orders_with_tps[0] tp_prices = [decimal.Decimal("100000"), decimal.Decimal("110000"), decimal.Decimal("120000")] assert len(buy_order.chained_orders) == len(tp_prices) for i, take_profit_order in enumerate(buy_order.chained_orders): is_last = i == len(buy_order.chained_orders) - 1 assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == ( decimal.Decimal("0.01") - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) ) / decimal.Decimal(str(len(tp_prices))) assert take_profit_order.order_group is None assert take_profit_order.origin_price == tp_prices[i] assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.update_with_triggering_order_fees == is_last assert take_profit_order.trailing_profile is None assert take_profit_order.is_active is True # only 2 additional (2 in total) data = { consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [decimal.Decimal("110000"), decimal.Decimal("120000")], consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.TRAILING_PROFILE: None } orders_with_tps = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state, data=data) buy_order = orders_with_tps[0] tp_prices = [decimal.Decimal("110000"), decimal.Decimal("120000")] assert len(buy_order.chained_orders) == len(tp_prices) for i, take_profit_order in enumerate(buy_order.chained_orders): is_last = i == len(buy_order.chained_orders) - 1 assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == ( decimal.Decimal("0.01") - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) ) / decimal.Decimal(str(len(tp_prices))) assert take_profit_order.origin_price == tp_prices[i] assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.update_with_triggering_order_fees == is_last assert take_profit_order.trailing_profile is None assert take_profit_order.is_active is True # only 2 additional with volume (2 in total) volume_ratios = [decimal.Decimal("1"), decimal.Decimal("1.2")] data = { consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [decimal.Decimal("110000"), decimal.Decimal("120000")], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: volume_ratios, consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.TRAILING_PROFILE: None } orders_with_tps = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state, data=data) buy_order = orders_with_tps[0] tp_prices = [decimal.Decimal("110000"), decimal.Decimal("120000")] assert len(buy_order.chained_orders) == len(tp_prices) for i, take_profit_order in enumerate(buy_order.chained_orders): is_last = i == len(buy_order.chained_orders) - 1 assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == ( decimal.Decimal("0.01") - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) ) * volume_ratios[i] / sum(volume_ratios) assert take_profit_order.origin_price == tp_prices[i] assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.update_with_triggering_order_fees == is_last assert take_profit_order.trailing_profile is None assert take_profit_order.is_active is True # stop loss and 1 take profit and 5 additional with volume data (6 TP in total) exchange_manager.trader.enable_inactive_orders = True tp_prices = [ decimal.Decimal("100012"), decimal.Decimal("110000"), decimal.Decimal("120000"), decimal.Decimal("130000"), decimal.Decimal("140000"), decimal.Decimal("150000") ] tp_volumes = [ decimal.Decimal(str(val)) for val in ( 1, 2, 2.5, 2, 3, 2 ) ] data = { consumer.STOP_PRICE_KEY: decimal.Decimal("123"), consumer.TAKE_PROFIT_PRICE_KEY: tp_prices[0], consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: tp_prices[1:], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: tp_volumes, # inclue volume of 1st TP consumer.VOLUME_KEY: decimal.Decimal("0.01"), } orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.4)), state, data=data) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 1 + len(tp_prices) stop_order = buy_order.chained_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, stop_order.quantity_currency) assert stop_order.origin_price == decimal.Decimal("123") assert stop_order.is_waiting_for_chained_trigger assert stop_order.associated_entry_ids == [buy_order.order_id] assert stop_order.update_with_triggering_order_fees is True assert stop_order.trailing_profile is None assert stop_order.is_active is True assert len(buy_order.chained_orders[1:]) == len(tp_prices) for i, take_profit_order in enumerate(buy_order.chained_orders[1:]): is_last = i == len(buy_order.chained_orders[1:]) - 1 assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == ( decimal.Decimal("0.01") - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) ) * tp_volumes[i] / sum(tp_volumes) assert take_profit_order.origin_price == tp_prices[i] assert take_profit_order.is_active is False assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert isinstance(stop_order.order_group, trading_personal_data.BalancedTakeProfitAndStopOrderGroup) assert take_profit_order.order_group is stop_order.order_group assert take_profit_order.update_with_triggering_order_fees == is_last assert take_profit_order.trailing_profile is None async def test_chained_multiple_take_profit_with_filled_tp_trailing_stop_orders(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) exchange_manager.trader.enable_inactive_orders = True state = trading_enums.EvaluatorStates.LONG.value # stop loss and 1 take profit and 5 additional (6 TP in total) tp_prices = [ decimal.Decimal("100012"), decimal.Decimal("110000"), decimal.Decimal("120000"), decimal.Decimal("130000"), decimal.Decimal("140000"), decimal.Decimal("150000") ] data = { consumer.STOP_PRICE_KEY: decimal.Decimal("123"), consumer.TAKE_PROFIT_PRICE_KEY: tp_prices[0], consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: tp_prices[1:], consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.TRAILING_PROFILE: trading_personal_data.TrailingProfileTypes.FILLED_TAKE_PROFIT.value, } orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.4)), state, data=data) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 1 + len(tp_prices) stop_order = buy_order.chained_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.origin_quantity == decimal.Decimal("0.01") \ - trading_personal_data.get_fees_for_currency(buy_order.fee, stop_order.quantity_currency) assert stop_order.origin_price == decimal.Decimal("123") assert stop_order.is_waiting_for_chained_trigger assert stop_order.associated_entry_ids == [buy_order.order_id] assert stop_order.update_with_triggering_order_fees is True assert stop_order.is_active is True assert stop_order.trailing_profile == trading_personal_data.FilledTakeProfitTrailingProfile([ trading_personal_data.TrailingPriceStep(float(trailing_price), float(trigger_price), True) for trailing_price, trigger_price in zip([buy_order.origin_price] + tp_prices[:-1], tp_prices) ]) assert len(buy_order.chained_orders[1:]) == len(tp_prices) for i, take_profit_order in enumerate(buy_order.chained_orders[1:]): is_last = i == len(buy_order.chained_orders[1:]) - 1 assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.origin_quantity == ( decimal.Decimal("0.01") - trading_personal_data.get_fees_for_currency(buy_order.fee, take_profit_order.quantity_currency) ) / decimal.Decimal(str(len(tp_prices))) assert take_profit_order.origin_price == tp_prices[i] assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert take_profit_order.is_active is False assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert isinstance(stop_order.order_group, trading_personal_data.TrailingOnFilledTPBalancedOrderGroup) assert take_profit_order.order_group is stop_order.order_group assert take_profit_order.update_with_triggering_order_fees == is_last assert take_profit_order.trailing_profile is None async def test_create_stop_loss_orders(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools exchange_manager.trader.enable_inactive_orders = True # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) state = trading_enums.EvaluatorStates.SHORT.value data = { consumer.STOP_PRICE_KEY: decimal.Decimal("10"), consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.STOP_ONLY: True } created_orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.6)), state, data=data) assert len(created_orders) == 1 stop_order = created_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.origin_quantity == decimal.Decimal("0.01") assert stop_order.origin_price == decimal.Decimal("10") assert stop_order.side is trading_enums.TradeOrderSide.SELL assert stop_order.is_waiting_for_chained_trigger is False assert stop_order.update_with_triggering_order_fees is False # not chained order assert stop_order.tag is None assert stop_order.is_active is True assert stop_order.is_open() state = trading_enums.EvaluatorStates.LONG.value data = { consumer.STOP_PRICE_KEY: decimal.Decimal("5"), consumer.VOLUME_KEY: decimal.Decimal("0.01"), consumer.STOP_ONLY: True, consumer.TAG_KEY: "plop1" } created_orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-0.6)), state, data=data) assert len(created_orders) == 1 stop_order = created_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.origin_quantity == decimal.Decimal("0.01") assert stop_order.origin_price == decimal.Decimal("5") assert stop_order.side is trading_enums.TradeOrderSide.BUY assert stop_order.is_waiting_for_chained_trigger is False assert stop_order.tag == "plop1" assert stop_order.is_active is True assert stop_order.is_open() async def test_get_limit_quantity_from_risk(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools ctx = script_keywords.get_base_context(consumer.trading_mode, symbol) last_btc_price = 100 trading_api.force_set_mark_price(exchange_manager, symbol, last_btc_price) exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[ symbol] = last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(last_btc_price * 10 + 1000)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.current_crypto_currencies_values["BTC"] = \ decimal.Decimal(str(last_btc_price)) # with user amount consumer.MAX_CURRENCY_RATIO = decimal.Decimal(1) consumer.trading_mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = 10 consumer.trading_mode.trading_config[trading_constants.CONFIG_SELL_ORDER_AMOUNT] = 10 assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("10") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.5") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("9.9") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("1.9") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("1.9") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") # decreasing position assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, False) == decimal.Decimal("10") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, False) == decimal.Decimal("10") # without user amount consumer.trading_mode.trading_config.pop(trading_constants.CONFIG_BUY_ORDER_AMOUNT) consumer.trading_mode.trading_config.pop(trading_constants.CONFIG_SELL_ORDER_AMOUNT) consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.5") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("8.7") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("1.9") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("1.9") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") # decreasing position assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, False) == decimal.Decimal("15") assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, False) == decimal.Decimal("15") # all-in orders # 1. sell assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("1.9") consumer.SELL_WITH_MAXIMUM_SIZE_ORDERS = True # increasing position (would be 1.9 without all-in) assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("15") # decreasing position assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, False) == decimal.Decimal("15") # 2. buy assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("1.9") consumer.BUY_WITH_MAXIMUM_SIZE_ORDERS = True # increasing position (would be 1.9 without all-in) assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("15") # decreasing position assert await consumer._get_limit_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, False) == decimal.Decimal("15") async def test_get_market_quantity_from_risk(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools ctx = script_keywords.get_base_context(consumer.trading_mode, symbol) last_btc_price = 80 trading_api.force_set_mark_price(exchange_manager, symbol, last_btc_price) exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[ symbol] = last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(last_btc_price * 10 + 1000)) exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.current_crypto_currencies_values["BTC"] = \ decimal.Decimal(str(last_btc_price)) # with user amount consumer.MAX_CURRENCY_RATIO = decimal.Decimal(1) consumer.trading_mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = 10 consumer.trading_mode.trading_config[trading_constants.CONFIG_SELL_ORDER_AMOUNT] = 10 assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("10") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.5") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("10") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("2.125") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("2.125") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") # decreasing position assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, False) == decimal.Decimal("10") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, False) == decimal.Decimal("10") # without user amount consumer.trading_mode.trading_config.pop(trading_constants.CONFIG_BUY_ORDER_AMOUNT) consumer.trading_mode.trading_config.pop(trading_constants.CONFIG_SELL_ORDER_AMOUNT) consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.5") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("11.125") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("2.125") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("2.125") consumer.MAX_CURRENCY_RATIO = decimal.Decimal("0.1") # decreasing position assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, False) == decimal.Decimal("10.8") assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, False) == decimal.Decimal("10.8") # all-in orders # 1. sell assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("2.125") consumer.SELL_WITH_MAXIMUM_SIZE_ORDERS = True # increasing position (would be 2.125 without all-in) assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, True) == decimal.Decimal("15") # decreasing position assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", True, False) == decimal.Decimal("15") # 2. buy assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("2.125") consumer.BUY_WITH_MAXIMUM_SIZE_ORDERS = True # increasing position (would be 1.9 without all-in) assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, True) == decimal.Decimal("15") # decreasing position assert await consumer._get_market_quantity_from_risk(ctx, 1, decimal.Decimal(15), "BTC", False, False) == decimal.Decimal("15") async def test_target_profit_mode(tools): exchange_manager, trader, symbol, consumer, last_btc_price = tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) consumer.USE_TARGET_PROFIT_MODE = True _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data( exchange_manager, symbol=symbol, timeout=1 ) state = trading_enums.EvaluatorStates.LONG.value # take profit only consumer.USE_STOP_ORDERS = False orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), state) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 1 take_profit_order = buy_order.chained_orders[0] assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.side is trading_enums.TradeOrderSide.SELL assert take_profit_order.origin_quantity == buy_order.origin_quantity assert take_profit_order.reduce_only is False assert take_profit_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, buy_order.origin_price * (trading_constants.ONE + consumer.TARGET_PROFIT_TAKE_PROFIT) ) assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() exchange_manager.trader.enable_inactive_orders = True # stop loss and take profit consumer.USE_STOP_ORDERS = True orders_with_tp = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.4)), state) buy_order = orders_with_tp[0] assert len(buy_order.chained_orders) == 2 stop_order = buy_order.chained_orders[0] assert isinstance(stop_order, trading_personal_data.StopLossOrder) assert stop_order.side is trading_enums.TradeOrderSide.SELL assert stop_order.origin_quantity == buy_order.origin_quantity assert stop_order.reduce_only is False assert stop_order.is_active is True assert stop_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, buy_order.origin_price * (trading_constants.ONE - consumer.TARGET_PROFIT_STOP_LOSS) ) assert stop_order.is_waiting_for_chained_trigger assert stop_order.associated_entry_ids == [buy_order.order_id] assert not stop_order.is_open() assert not stop_order.is_created() take_profit_order = buy_order.chained_orders[1] assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert take_profit_order.side is trading_enums.TradeOrderSide.SELL assert take_profit_order.origin_quantity == buy_order.origin_quantity assert take_profit_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, buy_order.origin_price * (trading_constants.ONE + consumer.TARGET_PROFIT_TAKE_PROFIT) ) assert take_profit_order.is_waiting_for_chained_trigger assert take_profit_order.associated_entry_ids == [buy_order.order_id] assert not take_profit_order.is_open() assert not take_profit_order.is_created() assert take_profit_order.is_active is False assert isinstance(stop_order.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) assert take_profit_order.order_group is stop_order.order_group # stop loss and take profit but decreasing position size: do nothing in this mode # (this initial order is a take profit already) state = trading_enums.EvaluatorStates.SHORT.value orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(0.4)), state) assert orders == [] async def test_target_profit_mode_futures_trading(future_tools): exchange_manager, trader, symbol, consumer, last_btc_price = future_tools # with BTC/USDT exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter.last_prices_by_trading_pair[symbol] = \ last_btc_price exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(10 + 1000 / last_btc_price)) consumer.USE_TARGET_PROFIT_MODE = True consumer.TARGET_PROFIT_ENABLE_POSITION_INCREASE = True _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data( exchange_manager, symbol=symbol, timeout=1 ) exchange_manager.trader.enable_inactive_orders = True # take profit and stop loss / long signal consumer.TARGET_PROFIT_TAKE_PROFIT = decimal.Decimal(str(10)) consumer.TARGET_PROFIT_STOP_LOSS = decimal.Decimal(str(2.5)) consumer.USE_STOP_ORDERS = True long_orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(-1)), trading_enums.EvaluatorStates.LONG.value) buy_order = long_orders[0] assert isinstance(buy_order, trading_personal_data.BuyLimitOrder) assert len(buy_order.chained_orders) == 2 take_profit_order = buy_order.chained_orders[1] stop_loss_order = buy_order.chained_orders[0] assert isinstance(take_profit_order, trading_personal_data.SellLimitOrder) assert isinstance(stop_loss_order, trading_personal_data.StopLossOrder) # both are active on futures assert stop_loss_order.is_active is True assert take_profit_order.is_active is True assert take_profit_order.side is trading_enums.TradeOrderSide.SELL assert take_profit_order.origin_quantity == buy_order.origin_quantity assert take_profit_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, buy_order.origin_price * (trading_constants.ONE + consumer.TARGET_PROFIT_TAKE_PROFIT) ) assert stop_loss_order.side is trading_enums.TradeOrderSide.SELL assert stop_loss_order.origin_quantity == buy_order.origin_quantity assert stop_loss_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, buy_order.origin_price * (trading_constants.ONE - consumer.TARGET_PROFIT_STOP_LOSS) ) consumer.trading_mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "100q" # take profit and stop loss / short signal short_orders = await consumer.create_new_orders(symbol, decimal.Decimal(str(1)), trading_enums.EvaluatorStates.SHORT.value) sell_order = short_orders[0] assert sell_order.origin_quantity == decimal.Decimal('0.01426697') # 0.01739031 without 100q config assert isinstance(sell_order, trading_personal_data.SellLimitOrder) assert len(sell_order.chained_orders) == 2 take_profit_order = sell_order.chained_orders[1] stop_loss_order = sell_order.chained_orders[0] assert isinstance(take_profit_order, trading_personal_data.BuyLimitOrder) assert isinstance(stop_loss_order, trading_personal_data.StopLossOrder) assert take_profit_order.side is trading_enums.TradeOrderSide.BUY assert take_profit_order.origin_quantity == sell_order.origin_quantity assert take_profit_order.reduce_only is True assert take_profit_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, sell_order.origin_price * (trading_constants.ONE - consumer.TARGET_PROFIT_TAKE_PROFIT) ) assert stop_loss_order.side is trading_enums.TradeOrderSide.BUY assert stop_loss_order.origin_quantity == sell_order.origin_quantity assert stop_loss_order.reduce_only is True assert stop_loss_order.origin_price == trading_personal_data.decimal_adapt_price( symbol_market, sell_order.origin_price * (trading_constants.ONE + consumer.TARGET_PROFIT_STOP_LOSS) ) current_position = exchange_manager.exchange_personal_data.positions_manager \ .get_symbol_position( symbol, trading_enums.PositionSide.BOTH ) assert current_position.is_idle() await sell_order.on_fill(force_fill=True) assert not current_position.is_idle() short_orders_2 = await consumer.create_new_orders(symbol, decimal.Decimal(str(1)), trading_enums.EvaluatorStates.SHORT.value) # created order assert len(short_orders_2) == 1 consumer.TARGET_PROFIT_ENABLE_POSITION_INCREASE = False short_orders_2 = await consumer.create_new_orders(symbol, decimal.Decimal(str(1)), trading_enums.EvaluatorStates.SHORT.value) # did not create order as increasing position is disabled assert short_orders_2 == [] ================================================ FILE: Trading/Mode/daily_trading_mode/tests/test_daily_trading_mode_producer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import mock import pytest import os import os.path import asyncio import pytest_asyncio import async_channel.util as channel_util import octobot_backtesting.api as backtesting_api import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.tests.test_config as test_config import octobot_evaluators.api as evaluators_api import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.exchanges as exchanges import octobot_trading.signals as trading_signals import tentacles.Trading.Mode as Mode import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges import octobot_tentacles_manager.api as tentacles_manager_api # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def tools(symbol="BTC/USDT"): tentacles_manager_api.reload_tentacle_info() trader = None try: config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 1000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.DailyTradingMode(config, exchange_manager) await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) mode.get_trading_mode_consumers()[0].MAX_CURRENCY_RATIO = 1 # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) yield mode.producers[0], mode.get_trading_mode_consumers()[0], trader finally: if trader: await _stop(trader) async def _stop(trader): for importer in backtesting_api.get_importers(trader.exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await trader.exchange_manager.exchange.backtesting.stop() await trader.exchange_manager.stop() async def test_default_values(tools): producer, _, trader = tools assert producer.state is None async def test_set_state(tools): currency = "BTC" symbol = "BTC/USDT" time_frame = "1h" producer, consumer, trader = tools with mock.patch.object( consumer.trading_mode, "create_order", mock.AsyncMock(wraps=consumer.trading_mode.create_order) ) as create_order_mock: producer.final_eval = trading_constants.ZERO await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.NEUTRAL) assert producer.state == trading_enums.EvaluatorStates.NEUTRAL # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) create_order_mock.assert_not_called() producer.final_eval = decimal.Decimal(-1) await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.VERY_LONG) assert producer.state == trading_enums.EvaluatorStates.VERY_LONG _check_trades_count(trader, 0) # market order got filled await asyncio.create_task(_check_open_orders_count(trader, 0)) _check_trades_count(trader, 1) create_order_mock.assert_called_once() assert create_order_mock.mock_calls[0].kwargs["dependencies"] == None create_order_mock.reset_mock() producer.final_eval = trading_constants.ZERO await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.NEUTRAL) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) create_order_mock.assert_not_called() producer.final_eval = trading_constants.ONE await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.VERY_SHORT) assert producer.state == trading_enums.EvaluatorStates.VERY_SHORT # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) # market order was created create_order_mock.assert_called_once() assert create_order_mock.mock_calls[0].kwargs["dependencies"] == None create_order_mock.reset_mock() # market order got filled _check_trades_count(trader, 2) producer.final_eval = trading_constants.ZERO await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.NEUTRAL) assert producer.state == trading_enums.EvaluatorStates.NEUTRAL # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) create_order_mock.assert_not_called() async def _cancel_symbol_open_orders(*args, **kwargs): await origin_cancel_symbol_open_orders(*args, **kwargs) return ( True, trading_signals.get_orders_dependencies([mock.Mock(order_id="123"), mock.Mock(order_id="456-cancel_symbol_open_orders")]) ) async def _apply_cancel_policies(*args, **kwargs): await origin_apply_cancel_policies(*args, **kwargs) return ( True, trading_signals.get_orders_dependencies([mock.Mock(order_id="456-cancel_policy")]) ) origin_cancel_symbol_open_orders = producer.cancel_symbol_open_orders origin_apply_cancel_policies = producer.apply_cancel_policies producer.final_eval = decimal.Decimal(str(-0.5)) with mock.patch.object( producer, "cancel_symbol_open_orders", mock.AsyncMock(side_effect=_cancel_symbol_open_orders) ) as cancel_symbol_open_orders_mock, mock.patch.object( producer, "apply_cancel_policies", mock.AsyncMock(side_effect=_apply_cancel_policies) ) as apply_cancel_policies_mock: await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.LONG) cancel_symbol_open_orders_mock.assert_called_once_with(symbol) cancel_symbol_open_orders_mock.reset_mock() apply_cancel_policies_mock.assert_called_once_with() apply_cancel_policies_mock.reset_mock() assert producer.state == trading_enums.EvaluatorStates.LONG # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) create_order_mock.assert_called_once() # cancelled orders dependencies are forwarded to create_order expected_dependencies = trading_signals.get_orders_dependencies( [mock.Mock(order_id="456-cancel_policy"), mock.Mock(order_id="123"), mock.Mock(order_id="456-cancel_symbol_open_orders")] ) assert create_order_mock.mock_calls[0].kwargs["dependencies"] == expected_dependencies create_order_mock.reset_mock() producer.final_eval = trading_constants.ZERO await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.NEUTRAL) cancel_symbol_open_orders_mock.assert_not_called() apply_cancel_policies_mock.assert_not_called() assert producer.state == trading_enums.EvaluatorStates.NEUTRAL # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) create_order_mock.assert_not_called() producer.final_eval = decimal.Decimal(str(0.5)) await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.SHORT) apply_cancel_policies_mock.assert_called_once_with() apply_cancel_policies_mock.reset_mock() cancel_symbol_open_orders_mock.assert_called_once_with(symbol) cancel_symbol_open_orders_mock.reset_mock() assert producer.state == trading_enums.EvaluatorStates.SHORT # let both other be created await asyncio_tools.wait_asyncio_next_cycle() # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 2)) # has stop loss assert create_order_mock.call_count == 2 # cancelled orders dependencies are forwarded to all created orders assert create_order_mock.mock_calls[0].kwargs["dependencies"] == expected_dependencies assert create_order_mock.mock_calls[1].kwargs["dependencies"] == expected_dependencies create_order_mock.reset_mock() producer.final_eval = trading_constants.ZERO await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.NEUTRAL) cancel_symbol_open_orders_mock.assert_not_called() apply_cancel_policies_mock.assert_not_called() assert producer.state == trading_enums.EvaluatorStates.NEUTRAL # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 2)) create_order_mock.assert_not_called() async def test_get_delta_risk(tools): producer, consumer, trader = tools for i in range(0, 100, 1): trader.risk = decimal.Decimal(str(i / 100)) assert round(producer._get_delta_risk(), 6) \ == round(decimal.Decimal(str(producer.RISK_THRESHOLD * i / 100)), 6) async def test_create_state(tools): producer, consumer, trader = tools delta_risk = producer._get_delta_risk() for i in range(-100, 100, 1): producer.final_eval = decimal.Decimal(str(i / 100)) await producer.create_state(None, None) if producer.final_eval < producer.VERY_LONG_THRESHOLD + delta_risk: assert producer.state == trading_enums.EvaluatorStates.VERY_LONG elif producer.final_eval < producer.LONG_THRESHOLD + delta_risk: assert producer.state == trading_enums.EvaluatorStates.LONG elif producer.final_eval < producer.NEUTRAL_THRESHOLD - delta_risk: assert producer.state == trading_enums.EvaluatorStates.NEUTRAL elif producer.final_eval < producer.SHORT_THRESHOLD - delta_risk: assert producer.state == trading_enums.EvaluatorStates.SHORT else: assert producer.state == trading_enums.EvaluatorStates.VERY_SHORT async def test_set_final_eval(tools): currency = "BTC" symbol = "BTC/USDT" time_frame = "1h" producer, consumer, trader = tools matrix_id = evaluators_api.create_matrix() await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.SHORT) assert producer.state == trading_enums.EvaluatorStates.SHORT # let both other be created await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, 2)) # has stop loss producer.final_eval = "val" await producer.set_final_eval(matrix_id, currency, symbol, time_frame, commons_enums.TriggerSource.EVALUATION_MATRIX.value) assert producer.state == trading_enums.EvaluatorStates.SHORT # ensure did not change trading_enums.EvaluatorStates assert producer.final_eval == "val" # ensure did not change trading_enums.EvaluatorStates await asyncio.create_task(_check_open_orders_count(trader, 2)) # ensure did not change orders async def test_finalize(tools): currency = "BTC" symbol = "BTC/USDT" producer, consumer, trader = tools matrix_id = evaluators_api.create_matrix() await producer.finalize(trading_api.get_exchange_name(trader.exchange_manager), matrix_id, currency, symbol) assert producer.final_eval == trading_constants.ZERO await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.SHORT) assert producer.state == trading_enums.EvaluatorStates.SHORT # let both other be created await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, 2)) # has stop loss await producer._set_state(currency, symbol, trading_enums.EvaluatorStates.SHORT) await asyncio.create_task( _check_open_orders_count(trader, 2)) # ensure did not change orders because neutral state async def _check_open_orders_count(trader, count): assert len(trading_api.get_open_orders(trader.exchange_manager)) == count def _check_trades_count(trader, count): assert len(trading_api.get_trade_history(trader.exchange_manager)) == count ================================================ FILE: Trading/Mode/dca_trading_mode/__init__.py ================================================ from .dca_trading import DCATradingMode ================================================ FILE: Trading/Mode/dca_trading_mode/config/DCATradingMode.json ================================================ { "default_config": [ "SimpleStrategyEvaluator" ], "required_strategies": [ "SimpleStrategyEvaluator" ], "buy_order_amount": "50q", "exit_limit_orders_price_percent": 5, "minutes_before_next_buy": 10080, "trigger_mode": "Time based", "use_market_entry_orders": true, "use_secondary_entry_orders": false, "use_secondary_exit_orders": false, "use_stop_losses": false, "use_take_profit_exit_orders": false, "cancel_open_orders_at_each_entry": true, "enable_health_check": false, "health_check_orphan_funds_threshold": 15, "max_asset_holding_percent": 100 } ================================================ FILE: Trading/Mode/dca_trading_mode/dca_trading.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import decimal import enum import typing import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.evaluators_util as evaluators_util import octobot_commons.signals as commons_signals import octobot_evaluators.api as evaluators_api import octobot_evaluators.constants as evaluators_constants import octobot_evaluators.enums as evaluators_enums import octobot_evaluators.matrix as matrix import octobot_trading.modes as trading_modes import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.util as trading_util import octobot_trading.errors as trading_errors import octobot_trading.personal_data as trading_personal_data import octobot_trading.exchanges as trading_exchanges import octobot_trading.modes.script_keywords as script_keywords class TriggerMode(enum.Enum): TIME_BASED = "Time based" MAXIMUM_EVALUATORS_SIGNALS_BASED = "Maximum evaluators signals based" class DCATradingModeConsumer(trading_modes.AbstractTradingModeConsumer): AMOUNT_TO_BUY_IN_REF_MARKET = "amount_to_buy_in_reference_market" ENTRY_LIMIT_ORDERS_PRICE_PERCENT = "entry_limit_orders_price_percent" USE_MARKET_ENTRY_ORDERS = "use_market_entry_orders" USE_INIT_ENTRY_ORDERS = "use_init_entry_orders" USE_SECONDARY_ENTRY_ORDERS = "use_secondary_entry_orders" SECONDARY_ENTRY_ORDERS_COUNT = "secondary_entry_orders_count" SECONDARY_ENTRY_ORDERS_AMOUNT = "secondary_entry_orders_amount" SECONDARY_ENTRY_ORDERS_PRICE_PERCENT = "secondary_entry_orders_price_percent" DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER = decimal.Decimal("0.05") # 5% by default DEFAULT_SECONDARY_ENTRY_ORDERS_COUNT = 0 DEFAULT_SECONDARY_ENTRY_ORDERS_AMOUNT = "" DEFAULT_SECONDARY_ENTRY_ORDERS_PRICE_MULTIPLIER = DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER USE_TAKE_PROFIT_EXIT_ORDERS = "use_take_profit_exit_orders" EXIT_LIMIT_ORDERS_PRICE_PERCENT = "exit_limit_orders_price_percent" DEFAULT_EXIT_LIMIT_PRICE_MULTIPLIER = DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER USE_SECONDARY_EXIT_ORDERS = "use_secondary_exit_orders" SECONDARY_EXIT_ORDERS_COUNT = "secondary_exit_orders_count" SECONDARY_EXIT_ORDERS_PRICE_PERCENT = "secondary_exit_orders_price_percent" DEFAULT_SECONDARY_EXIT_ORDERS_COUNT = 0 DEFAULT_SECONDARY_EXIT_ORDERS_PRICE_MULTIPLIER = DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER USE_STOP_LOSSES = "use_stop_losses" STOP_LOSS_PRICE_PERCENT = "stop_loss_price_percent" DEFAULT_STOP_LOSS_ORDERS_PRICE_MULTIPLIER = 2 * DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER async def create_new_orders(self, symbol, _, state, **kwargs): current_order = None initial_dependencies = kwargs.get(self.CREATE_ORDER_DEPENDENCIES_PARAM, None) post_cancel_dependencies = None try: price = await trading_personal_data.get_up_to_date_price( self.exchange_manager, symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT ) symbol_market = self.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) created_orders = [] ctx = script_keywords.get_base_context(self.trading_mode, symbol) if state is trading_enums.EvaluatorStates.NEUTRAL.value: raise trading_errors.NotSupported(state) side = trading_enums.TradeOrderSide.BUY if state in ( trading_enums.EvaluatorStates.LONG.value, trading_enums.EvaluatorStates.VERY_LONG.value ) else trading_enums.TradeOrderSide.SELL secondary_quantity = None if user_amount := trading_modes.get_user_selected_order_amount( self.trading_mode, trading_enums.TradeOrderSide.BUY ): initial_entry_price = price if self.trading_mode.use_market_entry_orders else \ trading_personal_data.decimal_adapt_price( symbol_market, price * ( 1 - self.trading_mode.entry_limit_orders_price_multiplier if side is trading_enums.TradeOrderSide.BUY else 1 + self.trading_mode.entry_limit_orders_price_multiplier ) ) if self.trading_mode.cancel_open_orders_at_each_entry: post_cancel_dependencies = await self._cancel_existing_orders_if_replaceable( ctx, symbol, user_amount, price, initial_entry_price, side, symbol_market, initial_dependencies ) quantity = await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=user_amount, side=side.value, reduce_only=False, is_stop_order=False, use_total_holding=False, ) if self.trading_mode.use_secondary_entry_orders and self.trading_mode.secondary_entry_orders_amount: # compute secondary orders quantity before locking quantity from initial order secondary_quantity = await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=self.trading_mode.secondary_entry_orders_amount, side=side.value, reduce_only=False, is_stop_order=False, use_total_holding=False, ) else: self.logger.error( f"Missing {side.value} entry order quantity in {self.trading_mode.get_name()} configuration" f", please set the \"Amount per buy order\" value.") return [] # consider holdings only after orders have been cancelled current_symbol_holding, current_market_holding, market_quantity = ( trading_personal_data.get_portfolio_amounts( self.exchange_manager, symbol, price ) ) if self.exchange_manager.is_future: self.trading_mode.ensure_supported(symbol) # on futures, current_symbol_holding = current_market_holding = market_quantity initial_available_base_funds, _ = trading_personal_data.get_futures_max_order_size( self.exchange_manager, symbol, side, price, False, current_symbol_holding, market_quantity ) initial_available_quote_funds = initial_available_base_funds * price else: initial_available_quote_funds = current_market_holding \ if side is trading_enums.TradeOrderSide.BUY else current_symbol_holding if side is trading_enums.TradeOrderSide.BUY: initial_entry_order_type = trading_enums.TraderOrderType.BUY_MARKET \ if self.trading_mode.use_market_entry_orders else trading_enums.TraderOrderType.BUY_LIMIT else: initial_entry_order_type = trading_enums.TraderOrderType.SELL_MARKET \ if self.trading_mode.use_market_entry_orders else trading_enums.TraderOrderType.SELL_LIMIT adapted_entry_quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, symbol, initial_entry_order_type, quantity, initial_entry_price, side ) # initial entry orders_should_have_been_created = await self._create_entry_order( initial_entry_order_type, adapted_entry_quantity, initial_entry_price, symbol_market, symbol, created_orders, price, post_cancel_dependencies ) # secondary entries if self.trading_mode.use_secondary_entry_orders and self.trading_mode.secondary_entry_orders_count > 0: secondary_order_type = trading_enums.TraderOrderType.BUY_LIMIT \ if side is trading_enums.TradeOrderSide.BUY else trading_enums.TraderOrderType.SELL_LIMIT if not secondary_quantity: if self.trading_mode.secondary_entry_orders_amount: self.logger.warning( f"Impossible to create {side.value} secondary entry order: computed quantity is {secondary_quantity}, " f"configured quantity is: {self.trading_mode.secondary_entry_orders_amount}." ) else: self.logger.error( f"Missing {side.value} secondary entry order quantity in {self.trading_mode.get_name()} " f"configuration, please set the \"Secondary entry orders count\" value " f"when enabling secondary entry orders." ) else: for i in range(self.trading_mode.secondary_entry_orders_count): remaining_funds = initial_available_quote_funds - sum( trading_personal_data.get_locked_funds(order) for order in created_orders ) adapted_secondary_quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, symbol, initial_entry_order_type, secondary_quantity, initial_entry_price, side ) skip_other_orders = adapted_secondary_quantity != secondary_quantity if skip_other_orders or remaining_funds < ( (secondary_quantity * initial_entry_price) if side is trading_enums.TradeOrderSide.BUY else secondary_quantity ): self.logger.debug( f"Not enough available funds to create {symbol} {i + 1}/" f"{self.trading_mode.secondary_entry_orders_count} secondary order with quantity of " f"{secondary_quantity} on {self.exchange_manager.exchange_name}" ) continue multiplier = self.trading_mode.entry_limit_orders_price_multiplier + \ (i + 1) * self.trading_mode.secondary_entry_orders_price_multiplier secondary_target_price = price * ( (1 - multiplier) if side is trading_enums.TradeOrderSide.BUY else (1 + multiplier) ) if not await self._create_entry_order( secondary_order_type, secondary_quantity, secondary_target_price, symbol_market, symbol, created_orders, price, post_cancel_dependencies ): # stop iterating if an order can't be created self.logger.info( f"Stopping {self.exchange_manager.exchange_name} {symbol} entry orders creation " f"on secondary order {i + 1}/{self.trading_mode.secondary_entry_orders_count}." ) break if created_orders: return created_orders if orders_should_have_been_created: raise trading_errors.OrderCreationError() raise trading_errors.MissingMinimalExchangeTradeVolume() except (trading_errors.MissingFunds, trading_errors.MissingMinimalExchangeTradeVolume, trading_errors.OrderCreationError, trading_errors.InvalidCancelPolicyError): raise except Exception as err: self.logger.exception( err, True, f"Failed to create order : {err}. Order: {current_order if current_order else None}" ) return [] async def _cancel_existing_orders_if_replaceable( self, ctx, symbol, user_amount, price, initial_entry_price, side, symbol_market, dependencies ) -> typing.Optional[commons_signals.SignalDependencies]: next_step_dependencies = None if to_cancel_orders := [ order for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol=symbol) if not (order.is_cancelled() or order.is_closed() or order.is_partially_filled()) and side is order.side ]: # Cancel existing DCA orders of the same side from previous iterations # Edge cases about cancelling existing orders when recreating entry orders # 1. max holding ratio is reached, meaning that portfolio + open orders already contain the # max % of asset # => in this case, we still want to be able to replace open orders of any. # Need to cancel open orders 1st # 2. value of the portfolio or available holdings dropped to the point that user configured # amount # is now too small to comply with min exchange rules. # => in this case, orders won't be able to be created. # Open orders should not be cancelled # Conclusion: # => Always cancel orders first except when exchange min amount would be reached in new # buy orders next_step_dependencies = commons_signals.SignalDependencies() can_create_entries = await self._can_create_entry_orders_regarding_min_exchange_order_size( ctx, user_amount, price, initial_entry_price, side, symbol_market, to_cancel_orders ) if can_create_entries: for order in to_cancel_orders: try: is_cancelled, new_dependencies = await self.trading_mode.cancel_order( order, dependencies=dependencies ) if is_cancelled: next_step_dependencies.extend(new_dependencies) except trading_errors.UnexpectedExchangeSideOrderStateError as err: self.logger.warning(f"Skipped order cancel: {err}, order: {order}") else: self.logger.info( f"Skipping {self.exchange_manager.exchange_name} {symbol} entry order cancel as new " f"entries are likely not complying with exchange minimal order size." ) return next_step_dependencies or dependencies async def _can_create_entry_orders_regarding_min_exchange_order_size( self, ctx, user_amount, price, initial_entry_price, side, symbol_market, to_cancel_orders ): quantity = await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=user_amount, side=side.value, reduce_only=False, is_stop_order=False, use_total_holding=False, orders_to_be_ignored=to_cancel_orders, # consider existing orders as cancelled ) can_create_entries = self._is_above_exchange_min_order_size(quantity, initial_entry_price, symbol_market) if ( can_create_entries and self.trading_mode.use_secondary_entry_orders and self.trading_mode.secondary_entry_orders_amount ): # compute secondary orders quantity before locking quantity from initial order if secondary_quantity := await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=self.trading_mode.secondary_entry_orders_amount, side=side.value, reduce_only=False, is_stop_order=False, use_total_holding=False, orders_to_be_ignored=to_cancel_orders, # consider existing orders as cancelled ): # check that at least the 1st secondary order can be created multiplier = self.trading_mode.entry_limit_orders_price_multiplier + ( 1 * self.trading_mode.secondary_entry_orders_price_multiplier ) secondary_target_price = price * ( (1 - multiplier) if side is trading_enums.TradeOrderSide.BUY else (1 + multiplier) ) can_create_entries = self._is_above_exchange_min_order_size( secondary_quantity, secondary_target_price, symbol_market ) return can_create_entries def _is_above_exchange_min_order_size(self, quantity, price, symbol_market): return bool( trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, price, symbol_market ) ) async def _create_entry_order( self, order_type, quantity, price, symbol_market, symbol, created_orders, current_price, dependencies ): if self._is_max_asset_ratio_reached(symbol): # do not create entry on symbol when max ratio is reached return False for order_quantity, order_price in \ trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, price, symbol_market ): entry_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=symbol, current_price=current_price, quantity=order_quantity, price=order_price ) created_at_least_one_order = False try: if created_order := await self._create_entry_with_chained_exit_orders( entry_order, price, symbol_market, dependencies ): created_orders.append(created_order) created_at_least_one_order = True return True except trading_errors.MaxOpenOrderReachedForSymbolError as err: self.logger.warning( f"Impossible to create {symbol} entry ({entry_order.side.value}) order: " f"creating more orders would exceed {self.exchange_manager.exchange_name}'s limits: {err}" ) return created_at_least_one_order try: buying = order_type in (trading_enums.TraderOrderType.BUY_MARKET, trading_enums.TraderOrderType.BUY_LIMIT) parsed_symbol = symbol_util.parse_symbol(symbol) missing_currency = parsed_symbol.quote if buying else parsed_symbol.base settlement_asset = parsed_symbol.settlement_asset if parsed_symbol.is_future() else parsed_symbol.quote quantity_currency = trading_personal_data.get_order_quantity_currency(self.exchange_manager, symbol) if parsed_symbol.is_spot(): cost = quantity * price else: cost = quantity min_cost = trading_personal_data.get_minimal_order_cost(symbol_market, default_price=float(price)) min_amount = trading_personal_data.get_minimal_order_amount(symbol_market) self.logger.info( f"Please get more {missing_currency}: {symbol} {order_type.value} not created on " f"{self.exchange_manager.exchange_name}: exchange order requirements are not met. " f"Attempted order cost: {cost} {settlement_asset}, quantity: {quantity} {quantity_currency}, " f"price: {price}, min cost: {min_cost} {settlement_asset}, min amount: {min_amount} {quantity_currency}" ) except Exception as err: self.logger.exception(err, True, f"Error when creating error message {err}") return False async def _create_entry_with_chained_exit_orders( self, entry_order, entry_price, symbol_market, dependencies ): params = {} exit_side = ( trading_enums.TradeOrderSide.SELL if entry_order.side is trading_enums.TradeOrderSide.BUY else trading_enums.TradeOrderSide.BUY ) exit_multiplier_side_flag = 1 if exit_side is trading_enums.TradeOrderSide.SELL else -1 total_exists_count = 1 + ( self.trading_mode.secondary_exit_orders_count if self.trading_mode.use_secondary_exit_orders else 0 ) stop_price = entry_price * ( trading_constants.ONE - ( self.trading_mode.stop_loss_price_multiplier * exit_multiplier_side_flag ) ) first_sell_price = entry_price * ( trading_constants.ONE + ( self.trading_mode.exit_limit_orders_price_multiplier * exit_multiplier_side_flag ) ) last_sell_price = entry_price * ( trading_constants.ONE + ( self.trading_mode.secondary_exit_orders_price_multiplier * (1 + self.trading_mode.secondary_exit_orders_count) * exit_multiplier_side_flag ) ) # split entry into multiple exits if necessary (and possible) exit_quantities = self._split_entry_quantity( entry_order.origin_quantity, total_exists_count, min(stop_price, first_sell_price, last_sell_price), max(stop_price, first_sell_price, last_sell_price), symbol_market ) can_bundle_exit_orders = len(exit_quantities) == 1 reduce_only_chained_orders = self.exchange_manager.is_future exit_orders = [] # 1. ensure entry order can be created if entry_order.order_type not in ( trading_enums.TraderOrderType.BUY_MARKET, trading_enums.TraderOrderType.SELL_MARKET ): trading_personal_data.ensure_orders_limit( self.exchange_manager, entry_order.symbol, [trading_enums.TraderOrderType.BUY_LIMIT] ) for i, exit_quantity in exit_quantities: is_last = i == len(exit_quantities) order_couple = [] # stop loss if self.trading_mode.use_stop_loss: # 1. ensure order can be created exit_orders.append(trading_enums.TraderOrderType.STOP_LOSS) trading_personal_data.ensure_orders_limit(self.exchange_manager, entry_order.symbol, exit_orders) # 2. initialize order stop_price = trading_personal_data.decimal_adapt_price(symbol_market, stop_price) param_update, chained_order = await self.register_chained_order( entry_order, stop_price, trading_enums.TraderOrderType.STOP_LOSS, exit_side, quantity=exit_quantity, allow_bundling=can_bundle_exit_orders, reduce_only=reduce_only_chained_orders, # only the last order is to take trigger fees into account update_with_triggering_order_fees=is_last and not self.exchange_manager.is_future ) params.update(param_update) order_couple.append(chained_order) # take profit if self.trading_mode.use_take_profit_exit_orders: # 1. ensure order can be created take_profit_order_type = self.exchange_manager.trader.get_take_profit_order_type( entry_order, trading_enums.TraderOrderType.BUY_LIMIT if exit_side is trading_enums.TradeOrderSide.BUY else trading_enums.TraderOrderType.SELL_LIMIT ) exit_orders.append(take_profit_order_type) trading_personal_data.ensure_orders_limit(self.exchange_manager, entry_order.symbol, exit_orders) # 2. initialize order take_profit_multiplier = self.trading_mode.exit_limit_orders_price_multiplier \ if i == 1 else ( self.trading_mode.exit_limit_orders_price_multiplier + self.trading_mode.secondary_exit_orders_price_multiplier * i ) take_profit_price = trading_personal_data.decimal_adapt_price( symbol_market, entry_price * ( trading_constants.ONE + (take_profit_multiplier * exit_multiplier_side_flag) ) ) param_update, chained_order = await self.register_chained_order( entry_order, take_profit_price, take_profit_order_type, None, quantity=exit_quantity, allow_bundling=can_bundle_exit_orders, reduce_only=reduce_only_chained_orders, # only the last order is to take trigger fees into account update_with_triggering_order_fees=is_last and not self.exchange_manager.is_future ) params.update(param_update) order_couple.append(chained_order) if len(order_couple) > 1: oco_group = self.exchange_manager.exchange_personal_data.orders_manager.create_group( trading_personal_data.OneCancelsTheOtherOrderGroup, active_order_swap_strategy=trading_personal_data.StopFirstActiveOrderSwapStrategy() ) for order in order_couple: order.add_to_order_group(oco_group) # in futures, inactive orders are not necessary if self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future: await oco_group.active_order_swap_strategy.apply_inactive_orders(order_couple) return await self.trading_mode.create_order( entry_order, params=params or None, dependencies=dependencies ) def _is_max_asset_ratio_reached(self, symbol): if self.exchange_manager.is_future: # not implemented for futures return False asset = symbol_util.parse_symbol(symbol).base ratio = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio(asset, include_assets_in_open_orders=True) if ratio >= self.trading_mode.max_asset_holding_ratio: self.logger.info( f"Max holding ratio reached for {asset}: ratio: {ratio}, max ratio: " f"{self.trading_mode.max_asset_holding_ratio}. Skipping {symbol} entry order." ) return True return False @staticmethod def _split_entry_quantity(quantity, target_exits_count, lowest_price, highest_price, symbol_market): if target_exits_count == 1: return [(1, quantity)] adapted_sell_orders_count, increment = trading_personal_data.get_split_orders_count_and_increment( lowest_price, highest_price, quantity, target_exits_count, symbol_market, False ) if adapted_sell_orders_count: return [ ( i + 1, trading_personal_data.decimal_adapt_quantity(symbol_market, quantity / adapted_sell_orders_count) ) for i in range(adapted_sell_orders_count) ] else: return [] def skip_portfolio_available_check_before_creating_orders(self) -> bool: """ When returning true, will skip portfolio available funds check before calling self.create_new_orders(). Override if necessary """ # will cancel open orders: skip available checks return self.trading_mode.cancel_open_orders_at_each_entry class DCATradingModeProducer(trading_modes.AbstractTradingModeProducer): MINUTES_BEFORE_NEXT_BUY = "minutes_before_next_buy" TRIGGER_MODE = "trigger_mode" CANCEL_OPEN_ORDERS_AT_EACH_ENTRY = "cancel_open_orders_at_each_entry" HEALTH_CHECK_ORPHAN_FUNDS_THRESHOLD = "health_check_orphan_funds_threshold" MAX_ASSET_HOLDING_PERCENT = "max_asset_holding_percent" def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) self.task = None self.state = trading_enums.EvaluatorStates.NEUTRAL async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() if self.task is not None: self.task.cancel() await super().stop() async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): evaluations = [] # Strategies analysis for evaluated_strategy_node in matrix.get_tentacles_value_nodes( matrix_id, matrix.get_tentacle_nodes(matrix_id, exchange_name=self.exchange_name, tentacle_type=evaluators_enums.EvaluatorMatrixTypes.STRATEGIES.value), cryptocurrency=cryptocurrency, symbol=symbol): if evaluators_util.check_valid_eval_note(evaluators_api.get_value(evaluated_strategy_node), evaluators_api.get_type(evaluated_strategy_node), evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE): evaluations.append(evaluators_api.get_value(evaluated_strategy_node)) is_forced_init_entry = self._should_trigger_init_entry() if evaluations or is_forced_init_entry: state = trading_enums.EvaluatorStates.NEUTRAL if is_forced_init_entry: self.logger.info( f"Triggering {self.trading_mode.symbol} init entries [{self.exchange_manager.exchange_name}]" ) state = trading_enums.EvaluatorStates.VERY_LONG elif all( evaluation == -1 for evaluation in evaluations ): state = trading_enums.EvaluatorStates.VERY_LONG elif all( evaluation == 1 for evaluation in evaluations ): state = trading_enums.EvaluatorStates.VERY_SHORT self.final_eval = evaluations try: await self.trigger_dca(cryptocurrency=cryptocurrency, symbol=symbol, state=state) finally: try: self.trading_mode.are_initialization_orders_pending = False except AttributeError: if self.trading_mode is None: # can very rarely happen on early cancelled backtestings self.logger.warning( f"{self.__class__.__name__} has already been stopped, skipping are_initialization_orders_pending setting" ) else: # unexpected error, raise raise def _should_trigger_init_entry(self): if self.trading_mode.enable_initialization_entry: return self.trading_mode.are_initialization_orders_pending return False async def trigger_dca(self, cryptocurrency: str, symbol: str, state: trading_enums.EvaluatorStates): if self.trading_mode.max_asset_holding_ratio < trading_constants.ONE: # if holding ratio should be checked, wait for price init to be able to compute this ratio await self._wait_for_symbol_prices_and_profitability_init(self._get_config_init_timeout()) self.state = state self.logger.debug( f"{symbol} DCA triggered on {self.exchange_manager.exchange_name}, state: {self.state.value}" ) if self.state is trading_enums.EvaluatorStates.NEUTRAL: self.last_activity = trading_modes.TradingModeActivity(trading_enums.TradingModeActivityType.NOTHING_TO_DO) else: self.last_activity = trading_modes.TradingModeActivity(trading_enums.TradingModeActivityType.CREATED_ORDERS) await self._process_entries(cryptocurrency, symbol, state) await self._process_exits(cryptocurrency, symbol, state) async def _process_pre_entry_actions(self, symbol: str, side=trading_enums.PositionSide.BOTH): try: # if position is idle, ensure leverage is set according to configuration if ( self.exchange_manager.is_future and self.exchange_manager.exchange_personal_data.positions_manager.get_symbol_position( symbol, side ).is_idle() ): config_leverage = await script_keywords.user_select_leverage( script_keywords.get_base_context(self.trading_mode, symbol=symbol), def_val=0 ) if config_leverage: parsed_leverage = decimal.Decimal(str(config_leverage)) current_leverage = self.exchange_manager.exchange.get_pair_future_contract(symbol).current_leverage if parsed_leverage != current_leverage: self.logger.info(f"Updating leverage of {symbol} from {current_leverage} to {parsed_leverage}") await self.trading_mode.set_leverage(symbol, side, parsed_leverage) except Exception as err: self.logger.exception( err, True, f"Error when processing pre_state_update_actions: {err} ({symbol=} {side=})" ) async def _process_entries(self, cryptocurrency: str, symbol: str, state: trading_enums.EvaluatorStates): entry_side = trading_enums.TradeOrderSide.BUY if state in ( trading_enums.EvaluatorStates.LONG, trading_enums.EvaluatorStates.VERY_LONG ) else trading_enums.TradeOrderSide.SELL if entry_side is trading_enums.TradeOrderSide.SELL: self.logger.debug(f"{entry_side.value} entry side not supported for now. Ignored state: {state.value})") return await self._process_pre_entry_actions(symbol) # call orders creation from consumers await self.submit_trading_evaluation( cryptocurrency=cryptocurrency, symbol=symbol, time_frame=None, final_note=None, state=state ) # send_notification await self._send_alert_notification(symbol, state, "entry") async def _process_exits(self, cryptocurrency: str, symbol: str, state: trading_enums.EvaluatorStates): # todo implement signal based exits pass async def dca_task(self): while not self.should_stop: try: for cryptocurrency, pairs in trading_util.get_traded_pairs_by_currency( self.exchange_manager.config ).items(): if self.trading_mode.symbol in pairs: await self.trigger_dca( cryptocurrency=cryptocurrency, symbol=self.trading_mode.symbol, state=trading_enums.EvaluatorStates.VERY_LONG ) if self.exchange_manager.is_backtesting: self.logger.error( f"{self.trading_mode.trigger_mode.value} trigger is not supporting backtesting for now. Please " f"configure another trigger mode to use {self.trading_mode.get_name()} in backtesting." ) return await asyncio.sleep(self.trading_mode.minutes_before_next_buy * commons_constants.MINUTE_TO_SECONDS) except Exception as e: self.logger.error(f"An error happened during DCA task : {e}") async def inner_start(self) -> None: await super().inner_start() if self.trading_mode.trigger_mode is TriggerMode.TIME_BASED: self.task = asyncio.create_task(self.delayed_start()) def get_channels_registration(self): registration_channels = [] if self.trading_mode.trigger_mode is TriggerMode.MAXIMUM_EVALUATORS_SIGNALS_BASED: topic = self.trading_mode.trading_config.get(commons_constants.CONFIG_ACTIVATION_TOPICS.replace(" ", "_"), commons_enums.ActivationTopics.EVALUATION_CYCLE.value) try: registration_channels.append(self.TOPIC_TO_CHANNEL_NAME[topic]) except KeyError: self.logger.error(f"Unknown registration topic: {topic}") return registration_channels def get_extra_init_symbol_topics(self) -> typing.Optional[list]: if self.exchange_manager.is_backtesting: # disabled in backtesting as price might not be initialized at this point return None # required as trigger can happen independently of price events when time based return [commons_enums.InitializationEventExchangeTopics.PRICE.value] async def delayed_start(self): await self.dca_task() async def _send_alert_notification(self, symbol, state, step): if self.exchange_manager.is_backtesting: return try: import octobot_services.api as services_api import octobot_services.enums as services_enum action = "unknown" if state in (trading_enums.EvaluatorStates.LONG, trading_enums.EvaluatorStates.VERY_LONG): action = "BUYING" elif state in (trading_enums.EvaluatorStates.SHORT, trading_enums.EvaluatorStates.VERY_SHORT): action = "SELLING" title = f"DCA {step} trigger for : #{symbol}" alert = f"{action} on {self.exchange_manager.exchange_name}" await services_api.send_notification(services_api.create_notification( alert, title=title, markdown_text=alert, category=services_enum.NotificationCategory.PRICE_ALERTS )) except ImportError as e: self.logger.exception(e, True, f"Impossible to send notification: {e}") class DCATradingMode(trading_modes.AbstractTradingMode): MODE_PRODUCER_CLASSES = [DCATradingModeProducer] MODE_CONSUMER_CLASSES = [DCATradingModeConsumer] SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION = True SUPPORTS_HEALTH_CHECK = True DEFAULT_HEALTH_CHECK_SELL_ORPHAN_FUNDS_RATIO_THRESHOLD = decimal.Decimal("0.1") # 10% HEALTH_CHECK_FILL_ORDERS_TIMEOUT = 20 def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.enable_initialization_entry = False self.use_market_entry_orders = False self.trigger_mode = TriggerMode.TIME_BASED self.minutes_before_next_buy = None self.entry_limit_orders_price_multiplier = DCATradingModeConsumer.DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER self.use_secondary_entry_orders = False self.secondary_entry_orders_count = DCATradingModeConsumer.DEFAULT_SECONDARY_ENTRY_ORDERS_COUNT self.secondary_entry_orders_amount = DCATradingModeConsumer.DEFAULT_SECONDARY_ENTRY_ORDERS_AMOUNT self.secondary_entry_orders_price_multiplier = DCATradingModeConsumer.DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER self.use_take_profit_exit_orders = False self.exit_limit_orders_price_multiplier = DCATradingModeConsumer.DEFAULT_EXIT_LIMIT_PRICE_MULTIPLIER self.use_secondary_exit_orders = False self.secondary_exit_orders_count = DCATradingModeConsumer.DEFAULT_SECONDARY_EXIT_ORDERS_COUNT self.secondary_exit_orders_price_multiplier = DCATradingModeConsumer.DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER self.use_stop_loss = False self.stop_loss_price_multiplier = DCATradingModeConsumer.DEFAULT_STOP_LOSS_ORDERS_PRICE_MULTIPLIER self.cancel_open_orders_at_each_entry = True self.health_check_orphan_funds_threshold = self.DEFAULT_HEALTH_CHECK_SELL_ORPHAN_FUNDS_RATIO_THRESHOLD self.max_asset_holding_ratio = trading_constants.ONE self.max_asset_holding_ratio = decimal.Decimal("0.5") # self.max_asset_holding_ratio = decimal.Decimal("0.66") # self.max_asset_holding_ratio = decimal.Decimal("1") # enable initialization orders self.are_initialization_orders_pending = True def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ default_config = self.get_default_config() self.trigger_mode = TriggerMode( self.UI.user_input( DCATradingModeProducer.TRIGGER_MODE, commons_enums.UserInputTypes.OPTIONS, default_config[DCATradingModeProducer.TRIGGER_MODE], inputs, options=[mode.value for mode in TriggerMode], title="Trigger mode: When should DCA entry orders should be triggered." ) ) self.minutes_before_next_buy = int(self.UI.user_input( DCATradingModeProducer.MINUTES_BEFORE_NEXT_BUY, commons_enums.UserInputTypes.INT, default_config[DCATradingModeProducer.MINUTES_BEFORE_NEXT_BUY], inputs, min_val=1, title="Trigger period: Minutes to wait between each transaction. Examples: 60 for 1 hour, 1440 for 1 day, " "10080 for 1 week or 43200 for 1 month.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeProducer.TRIGGER_MODE: TriggerMode.TIME_BASED.value } } )) self.enable_initialization_entry = self.UI.user_input( DCATradingModeConsumer.USE_INIT_ENTRY_ORDERS, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeConsumer.USE_INIT_ENTRY_ORDERS], inputs, title="Enable initialization entry orders: Automatically trigger entry orders " "when starting OctoBot, regardless of initial evaluator values.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeProducer.TRIGGER_MODE: TriggerMode.MAXIMUM_EVALUATORS_SIGNALS_BASED.value } } ) trading_modes.user_select_order_amount(self, inputs, include_sell=False) self.use_market_entry_orders = self.UI.user_input( DCATradingModeConsumer.USE_MARKET_ENTRY_ORDERS, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeConsumer.USE_MARKET_ENTRY_ORDERS], inputs, title="Use market orders instead of limit orders." ) self.entry_limit_orders_price_multiplier = decimal.Decimal(str( self.UI.user_input( DCATradingModeConsumer.ENTRY_LIMIT_ORDERS_PRICE_PERCENT, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeConsumer.ENTRY_LIMIT_ORDERS_PRICE_PERCENT] * trading_constants.ONE_HUNDRED), inputs, min_val=0, title="Limit entry percent difference: Price difference in percent to compute the entry price from " "when using limit orders. " "Example: 10 on a 2000 USDT price would create a buy limit price at 1800 USDT or " "a sell limit price at 2200 USDT.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_MARKET_ENTRY_ORDERS: False } } ) )) / trading_constants.ONE_HUNDRED self.use_secondary_entry_orders = self.UI.user_input( DCATradingModeConsumer.USE_SECONDARY_ENTRY_ORDERS, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeConsumer.USE_SECONDARY_ENTRY_ORDERS], inputs, title="Enable secondary entry orders: Split entry into multiple orders using different prices." ) self.secondary_entry_orders_count = self.UI.user_input( DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_COUNT, commons_enums.UserInputTypes.INT, default_config[DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_COUNT], inputs, title="Secondary entry orders count: Number of secondary limit orders to create alongside the initial " "entry order.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_SECONDARY_ENTRY_ORDERS: True } } ) self.secondary_entry_orders_price_multiplier = decimal.Decimal(str( self.UI.user_input( DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_PRICE_PERCENT, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_PRICE_PERCENT] * trading_constants.ONE_HUNDRED), inputs, title="Secondary entry orders price interval percent: Price difference in percent to compute the " "price of secondary entry orders compared to the price of the initial entry order. " "Example: 10 on a 1800 USDT entry buy (with an asset price of 2000) would " "create secondary entry buy orders at 1600 USDT, 1400 USDT and so on.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_SECONDARY_ENTRY_ORDERS: True } } ) )) / trading_constants.ONE_HUNDRED self.secondary_entry_orders_amount = self.UI.user_input( DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_AMOUNT, commons_enums.UserInputTypes.TEXT, default_config[DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_AMOUNT], inputs, title=f"Secondary entry orders amount: {trading_modes.get_order_amount_value_desc()}", other_schema_values={"minLength": 0}, editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_SECONDARY_ENTRY_ORDERS: True } } ) self.use_take_profit_exit_orders = self.UI.user_input( DCATradingModeConsumer.USE_TAKE_PROFIT_EXIT_ORDERS, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeConsumer.USE_TAKE_PROFIT_EXIT_ORDERS], inputs, title="Enable take profit exit orders: Automatically create take profit exit orders " "when entries are filled." ) self.exit_limit_orders_price_multiplier = decimal.Decimal(str( self.UI.user_input( DCATradingModeConsumer.EXIT_LIMIT_ORDERS_PRICE_PERCENT, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeConsumer.EXIT_LIMIT_ORDERS_PRICE_PERCENT] * trading_constants.ONE_HUNDRED), inputs, min_val=0, title="Limit exit percent difference: Price difference in percent to compute the exit price from " "after an entry is filled. " "Example: 10 on a 2000 USDT filled price buy would create a sell limit price at 2200 USDT.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_TAKE_PROFIT_EXIT_ORDERS: True } } ) )) / trading_constants.ONE_HUNDRED self.use_secondary_exit_orders = self.UI.user_input( DCATradingModeConsumer.USE_SECONDARY_EXIT_ORDERS, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeConsumer.USE_SECONDARY_EXIT_ORDERS], inputs, title="Enable secondary exit orders: Split each filled entry order into into multiple exit orders using " "different prices.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_TAKE_PROFIT_EXIT_ORDERS: True } } ) self.secondary_exit_orders_count = self.UI.user_input( DCATradingModeConsumer.SECONDARY_EXIT_ORDERS_COUNT, commons_enums.UserInputTypes.INT, default_config[DCATradingModeConsumer.SECONDARY_EXIT_ORDERS_COUNT], inputs, title="Secondary exit orders count: Number of secondary limit orders to create additionally to " "the initial exit order. When enabled, the entry filled amount is split into each exit orders.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_SECONDARY_EXIT_ORDERS: True } } ) self.secondary_exit_orders_price_multiplier = decimal.Decimal(str( self.UI.user_input( DCATradingModeConsumer.SECONDARY_EXIT_ORDERS_PRICE_PERCENT, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeConsumer.SECONDARY_EXIT_ORDERS_PRICE_PERCENT] * trading_constants.ONE_HUNDRED), inputs, title="Secondary exit orders price interval percent: Price difference in percent to compute the " "price of secondary exit orders compared to the price of the associated entry order. " "Example: 10 on a 2000 USDT exit sell price would create secondary exit sell orders " "at 2200 USDT, 2400 USDT and so on.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_SECONDARY_EXIT_ORDERS: True } } ) )) / trading_constants.ONE_HUNDRED self.use_stop_loss = self.UI.user_input( DCATradingModeConsumer.USE_STOP_LOSSES, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeConsumer.USE_STOP_LOSSES], inputs, title="Enable stop losses: Create stop losses when entries are filled.", ) self.stop_loss_price_multiplier = decimal.Decimal(str( self.UI.user_input( DCATradingModeConsumer.STOP_LOSS_PRICE_PERCENT, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeConsumer.STOP_LOSS_PRICE_PERCENT] * trading_constants.ONE_HUNDRED), inputs, min_val=0, max_val=100, title="Stop loss price percent: maximum percent losses to compute the stop loss price from. " "Example: a buy entry filled at 2000 with a Stop loss percent at" " 15 will create a stop order at 1700.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { DCATradingModeConsumer.USE_STOP_LOSSES: True } } ) )) / trading_constants.ONE_HUNDRED self.cancel_open_orders_at_each_entry = self.UI.user_input( DCATradingModeProducer.CANCEL_OPEN_ORDERS_AT_EACH_ENTRY, commons_enums.UserInputTypes.BOOLEAN, default_config[DCATradingModeProducer.CANCEL_OPEN_ORDERS_AT_EACH_ENTRY], inputs, title="Cancel open orders on each entry: Cancel existing orders from previous iteration on each entry.", ) self.is_health_check_enabled = self.UI.user_input( self.ENABLE_HEALTH_CHECK, commons_enums.UserInputTypes.BOOLEAN, default_config[self.ENABLE_HEALTH_CHECK], inputs, title="Health check: when enabled, OctoBot will automatically sell traded assets that are not associated " "to a sell order and that represent at least the 'Health check threshold' part of the " "portfolio. Health check can be useful to avoid inactive funds, for example if a buy order got " "filled but no sell order was created. Requires a common quote market for each traded pair. " "Warning: will sell any asset associated to a trading pair that is not covered by a sell order, " "even if not bought by OctoBot or this trading mode.", ) self.health_check_orphan_funds_threshold = decimal.Decimal(str( self.UI.user_input( DCATradingModeProducer.HEALTH_CHECK_ORPHAN_FUNDS_THRESHOLD, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeProducer.HEALTH_CHECK_ORPHAN_FUNDS_THRESHOLD] * trading_constants.ONE_HUNDRED), inputs, title="Health check threshold: Minimum % of the portfolio taken by a traded asset that is not in " "sell orders. Assets above this threshold will be sold for the common quote market during " "Health check.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { self.ENABLE_HEALTH_CHECK: True } } ) )) / trading_constants.ONE_HUNDRED self.max_asset_holding_ratio = decimal.Decimal(str( self.UI.user_input( DCATradingModeProducer.MAX_ASSET_HOLDING_PERCENT, commons_enums.UserInputTypes.FLOAT, float(default_config[DCATradingModeProducer.MAX_ASSET_HOLDING_PERCENT] * trading_constants.ONE_HUNDRED), inputs, title="Max asset holding: Maximum % of the portfolio to allocate to an asset. " "Buy orders to buy this asset won't be created if this ratio is reached. " "Only applied when trading on spot.", min_val=0, max_val=100 ) )) / trading_constants.ONE_HUNDRED @classmethod def get_default_config( cls, buy_amount: typing.Optional[str] = None, sell_amount: typing.Optional[str] = None, use_secondary_entry_orders: typing.Optional[bool] = None, secondary_entry_orders_count: typing.Optional[int] = None, exit_limit_orders_price_percent: typing.Optional[float] = None, entry_limit_orders_price_percent: typing.Optional[float] = None, secondary_entry_orders_price_percent: typing.Optional[float] = None, secondary_entry_orders_amount: typing.Optional[str] = None, enable_stop_loss: typing.Optional[bool] = None, stop_loss_price: typing.Optional[float] = None, use_init_entry_orders: typing.Optional[bool] = None, use_take_profit_exit_orders: typing.Optional[bool] = None, trigger_mode:typing. Optional[TriggerMode] = None, secondary_exit_orders_price_percent: typing.Optional[float] = None, health_check_orphan_funds_threshold: typing.Optional[float] = None, max_asset_holding_percent : typing.Optional[float] = None, ) -> dict: return { trading_constants.CONFIG_BUY_ORDER_AMOUNT: buy_amount, trading_constants.CONFIG_SELL_ORDER_AMOUNT: sell_amount, DCATradingModeProducer.TRIGGER_MODE: trigger_mode.value if trigger_mode else TriggerMode.TIME_BASED.value, DCATradingModeProducer.MINUTES_BEFORE_NEXT_BUY: 10080, DCATradingModeConsumer.USE_INIT_ENTRY_ORDERS: use_init_entry_orders or False, DCATradingModeConsumer.USE_MARKET_ENTRY_ORDERS: False, DCATradingModeConsumer.ENTRY_LIMIT_ORDERS_PRICE_PERCENT: entry_limit_orders_price_percent or DCATradingModeConsumer.DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER, DCATradingModeConsumer.USE_SECONDARY_ENTRY_ORDERS: use_secondary_entry_orders or False, DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_COUNT: secondary_entry_orders_count or DCATradingModeConsumer.DEFAULT_SECONDARY_ENTRY_ORDERS_COUNT, DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_PRICE_PERCENT: secondary_entry_orders_price_percent or DCATradingModeConsumer.DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER, DCATradingModeConsumer.SECONDARY_ENTRY_ORDERS_AMOUNT: secondary_entry_orders_amount or "", DCATradingModeConsumer.USE_TAKE_PROFIT_EXIT_ORDERS: use_take_profit_exit_orders or False, DCATradingModeConsumer.EXIT_LIMIT_ORDERS_PRICE_PERCENT: exit_limit_orders_price_percent or DCATradingModeConsumer.DEFAULT_EXIT_LIMIT_PRICE_MULTIPLIER, DCATradingModeConsumer.USE_SECONDARY_EXIT_ORDERS: False, DCATradingModeConsumer.SECONDARY_EXIT_ORDERS_COUNT: DCATradingModeConsumer.DEFAULT_SECONDARY_EXIT_ORDERS_COUNT, DCATradingModeConsumer.SECONDARY_EXIT_ORDERS_PRICE_PERCENT: secondary_exit_orders_price_percent or DCATradingModeConsumer.DEFAULT_ENTRY_LIMIT_PRICE_MULTIPLIER, DCATradingModeConsumer.USE_STOP_LOSSES: enable_stop_loss or False, DCATradingModeConsumer.STOP_LOSS_PRICE_PERCENT: stop_loss_price or DCATradingModeConsumer.DEFAULT_STOP_LOSS_ORDERS_PRICE_MULTIPLIER, DCATradingModeProducer.CANCEL_OPEN_ORDERS_AT_EACH_ENTRY: True, cls.ENABLE_HEALTH_CHECK: False, DCATradingModeProducer.HEALTH_CHECK_ORPHAN_FUNDS_THRESHOLD: health_check_orphan_funds_threshold or cls.DEFAULT_HEALTH_CHECK_SELL_ORPHAN_FUNDS_RATIO_THRESHOLD, DCATradingModeProducer.MAX_ASSET_HOLDING_PERCENT: max_asset_holding_percent or decimal.Decimal(1), } @classmethod def get_is_symbol_wildcard(cls) -> bool: return False @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_current_state(self) -> (str, float): return ( super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, ",".join([str(e) for e in self.producers[0].final_eval]) if self.producers[0].final_eval else self.producers[0].final_eval ) async def single_exchange_process_optimize_initial_portfolio( self, sellable_assets, target_asset: str, tickers: dict ) -> list: traded_coins = [ symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols ] sellable_assets = sorted(list(set(sellable_assets + traded_coins))) self.logger.info(f"Optimizing portfolio: selling {sellable_assets} to buy {target_asset}") return await trading_modes.convert_assets_to_target_asset( self, sellable_assets, target_asset, tickers ) async def single_exchange_process_health_check(self, chained_orders: list, tickers: dict) -> list: common_quote = trading_exchanges.get_common_traded_quote(self.exchange_manager) if ( common_quote is None or not (self.use_take_profit_exit_orders or self.use_stop_loss) ): # skipped when: # - common_quote is unset # - not using take profit or stop losses, health check should not be used return [] created_orders = [] for asset, amount in self._get_lost_funds_to_sell(common_quote, chained_orders): # sell lost funds self.logger.info( f"Health check: selling {amount} {asset} into {common_quote} on {self.exchange_manager.exchange_name}" ) try: asset_orders = await trading_modes.convert_asset_to_target_asset( self, asset, common_quote, tickers, asset_amount=amount ) if not asset_orders: self.logger.info( f"Health check: Not enough funds to create an order according to exchanges rules using " f"{amount} {asset} into {common_quote} on {self.exchange_manager.exchange_name}" ) else: created_orders.extend(asset_orders) except Exception as err: self.logger.exception( err, True, f"Error when creating order to sell {asset} into {common_quote}: {err}" ) if created_orders: await asyncio.gather( *[ trading_personal_data.wait_for_order_fill( order, self.HEALTH_CHECK_FILL_ORDERS_TIMEOUT, True ) for order in created_orders ] ) for producer in self.producers: producer.last_activity = trading_modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) return created_orders def _get_lost_funds_to_sell(self, common_quote: str, chained_orders: list) -> list[(str, decimal.Decimal)]: asset_and_amount = [] value_holder = self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder traded_base_assets = set( symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols ) sell_orders = [ order for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders() + chained_orders if order.side is trading_enums.TradeOrderSide.SELL ] partially_filled_buy_orders = [ order for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders() if order.side is trading_enums.TradeOrderSide.BUY and order.is_partially_filled() ] orphan_asset_values_by_asset = {} total_traded_assets_value = value_holder.value_converter.evaluate_value( common_quote, self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( common_quote ).total, target_currency=common_quote, init_price_fetchers=False ) for asset in traded_base_assets: asset_holding = \ self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( asset ) holdings_value = value_holder.value_converter.evaluate_value( asset, asset_holding.total, target_currency=common_quote, init_price_fetchers=False ) total_traded_assets_value += holdings_value holdings_in_sell_orders = sum( order.origin_quantity for order in sell_orders if symbol_util.parse_symbol(order.symbol).base == asset ) holdings_from_partially_filled_buy_orders = sum( order.filled_quantity for order in partially_filled_buy_orders if symbol_util.parse_symbol(order.symbol).base == asset ) # do not consider more than the available amounts orphan_amount = min( asset_holding.total - holdings_in_sell_orders - holdings_from_partially_filled_buy_orders, asset_holding.available ) if orphan_amount and orphan_amount > 0: orphan_asset_values_by_asset[asset] = ( holdings_value * orphan_amount / asset_holding.total, orphan_amount ) for asset, value_and_orphan_amount in orphan_asset_values_by_asset.items(): value, orphan_amount = value_and_orphan_amount ratio = value / total_traded_assets_value if ratio > self.health_check_orphan_funds_threshold: asset_and_amount.append((asset, orphan_amount)) return asset_and_amount ================================================ FILE: Trading/Mode/dca_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["DCATradingMode"], "tentacles-requirements": [] } ================================================ FILE: Trading/Mode/dca_trading_mode/resources/DCATradingMode.md ================================================ Dollar cost averaging (DCA) is a trading mode that can help you lower the amount you pay for investments and minimize risk. Instead of purchasing investments at a single price point, with dollar cost averaging you buy in smaller amounts at regular intervals.
OctoBot's DCA is more than just a simple regular DCA technique, it allows you to accurately automate your entries and exit conditions in a simple, yet very powerful way. To know more, checkout the full DCA trading mode guide. ### In a nutshell - Entries can be triggered either: - On a pure time base, regardless of price. - Upon enabled evaluators maximum signals (only 1 or -1 evaluations). In this case, the latest evaluation will prevail when using limit entry orders: previous evaluations open orders will be cancelled. - Entries can be market or limit orders. - Once an entry is filled, you can choose to exit/sell the assets yourself (manually) or automatically create a take profit at your price target. - You can enable stop losses protect your holdings once an entry is filled. - It is also possible to split entries and exits into multiple orders at regular price intervals to profit even more from the dollar cost averaging effect. Over the long term, dollar cost averaging can help lower your investment costs and boost your returns by optimizing entry and exit prices according to your goals. _Note: When using default configuration, DCA Trading mode will buy 50$ (or unit of the quote currency: USDT for BTC/USDT) each week._ _This trading mode supports PNL history when take profit exit orders are enabled._ ================================================ FILE: Trading/Mode/dca_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/dca_trading_mode/tests/test_dca_trading_mode.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import os.path import mock import decimal import asyncio import async_channel.util as channel_util import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.enums as commons_enum import octobot_commons.tests.test_config as test_config import octobot_commons.constants as commons_constants import octobot_commons.symbols as commons_symbols import octobot_backtesting.api as backtesting_api import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchanges as exchanges import octobot_trading.exchange_data as trading_exchange_data import octobot_trading.personal_data as trading_personal_data import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.modes import octobot_trading.errors import octobot_trading.signals as trading_signals import tentacles.Evaluator.TA as TA import tentacles.Evaluator.Strategies as Strategies import tentacles.Trading.Mode as Mode import tests.test_utils.memory_check_util as memory_check_util import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges import tentacles.Trading.Mode.dca_trading_mode.dca_trading as dca_trading # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def tools(): trader = None try: tentacles_manager_api.reload_tentacle_info() mode, trader = await _get_tools() yield mode, trader finally: if trader: await _stop(trader.exchange_manager) @pytest_asyncio.fixture async def futures_tools(): trader = None try: tentacles_manager_api.reload_tentacle_info() mode, trader = await _get_futures_tools() yield mode, trader finally: if trader: await _stop(trader.exchange_manager) async def test_run_independent_backtestings_with_memory_check(): """ Should always be called first here to avoid other tests' related memory check issues """ tentacles_setup_config = tentacles_manager_api.create_tentacles_setup_config_with_tentacles( Mode.DCATradingMode, Strategies.SimpleStrategyEvaluator, TA.RSIMomentumEvaluator, TA.EMAMomentumEvaluator ) config = test_config.load_test_config() config[commons_constants.CONFIG_TIME_FRAME] = [commons_enum.TimeFrames.FOUR_HOURS] _CONFIG = { Mode.DCATradingMode.get_name(): { "buy_order_amount": "50q", "default_config": [ "SimpleStrategyEvaluator" ], "entry_limit_orders_price_percent": 1, "exit_limit_orders_price_percent": 5, "minutes_before_next_buy": 10080, "required_strategies": [ "SimpleStrategyEvaluator", "TechnicalAnalysisStrategyEvaluator" ], "secondary_entry_orders_amount": "12%", "secondary_entry_orders_count": 0, "secondary_entry_orders_price_percent": 5, "secondary_exit_orders_count": 2, "secondary_exit_orders_price_percent": 5, "stop_loss_price_percent": 10, "trigger_mode": "Maximum evaluators signals based", "use_market_entry_orders": False, "use_secondary_entry_orders": True, "use_secondary_exit_orders": True, "use_stop_losses": True, "use_take_profit_exit_orders": True }, Strategies.SimpleStrategyEvaluator.get_name(): { "background_social_evaluators": [ "RedditForumEvaluator" ], "default_config": [ "DoubleMovingAverageTrendEvaluator", "RSIMomentumEvaluator" ], "re_evaluate_TA_when_social_or_realtime_notification": True, "required_candles_count": 1000, "required_evaluators": [ "*" ], "required_time_frames": [ "1h" ], "social_evaluators_notification_timeout": 3600 }, TA.RSIMomentumEvaluator.get_name(): { "long_threshold": 30, "period_length": 14, "short_threshold": 70, "trend_change_identifier": False }, TA.EMAMomentumEvaluator.get_name(): { "period_length": 14, "price_threshold_percent": 2 }, } def config_proxy(tentacles_setup_config, klass): try: return _CONFIG[klass if isinstance(klass, str) else klass.get_name()] except KeyError: return {} with tentacles_manager_api.local_tentacle_config_proxy(config_proxy): await memory_check_util.run_independent_backtestings_with_memory_check(config, tentacles_setup_config) def _get_config(tools, update): mode, trader = tools config = tentacles_manager_api.get_tentacle_config(trader.exchange_manager.tentacles_setup_config, mode.__class__) return {**config, **update} async def test_init_default_values(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) assert mode.use_market_entry_orders is True assert mode.trigger_mode is dca_trading.TriggerMode.TIME_BASED assert mode.minutes_before_next_buy == 10080 assert mode.entry_limit_orders_price_multiplier == decimal.Decimal("0.05") assert mode.use_secondary_entry_orders is False assert mode.secondary_entry_orders_count == 0 assert mode.secondary_entry_orders_amount == "" assert mode.secondary_entry_orders_price_multiplier == decimal.Decimal("0.05") assert mode.use_take_profit_exit_orders is False assert mode.exit_limit_orders_price_multiplier == decimal.Decimal("0.05") assert mode.use_secondary_exit_orders is False assert mode.secondary_exit_orders_count == 0 assert mode.secondary_exit_orders_price_multiplier == decimal.Decimal("0.05") assert mode.use_stop_loss is False assert mode.stop_loss_price_multiplier == decimal.Decimal("0.1") async def test_init_config_values(tools): update = { "buy_order_amount": "50q", "entry_limit_orders_price_percent": 3, "exit_limit_orders_price_percent": 1, "minutes_before_next_buy": 333, "secondary_entry_orders_amount": "12%", "secondary_entry_orders_count": 0, "secondary_entry_orders_price_percent": 5, "secondary_exit_orders_count": 333, "secondary_exit_orders_price_percent": 2, "stop_loss_price_percent": 10, "trigger_mode": "Maximum evaluators signals based", "use_market_entry_orders": False, "use_secondary_entry_orders": True, "use_secondary_exit_orders": True, "use_stop_losses": True, "use_take_profit_exit_orders": True } mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) assert mode.use_market_entry_orders is False assert mode.trigger_mode is dca_trading.TriggerMode.MAXIMUM_EVALUATORS_SIGNALS_BASED assert mode.minutes_before_next_buy == 333 assert mode.entry_limit_orders_price_multiplier == decimal.Decimal("0.03") assert mode.use_secondary_entry_orders is True assert mode.secondary_entry_orders_count == 0 assert mode.secondary_entry_orders_amount == "12%" assert mode.secondary_entry_orders_price_multiplier == decimal.Decimal("0.05") assert mode.use_take_profit_exit_orders is True assert mode.exit_limit_orders_price_multiplier == decimal.Decimal("0.01") assert mode.use_secondary_exit_orders is True assert mode.secondary_exit_orders_count == 333 assert mode.secondary_exit_orders_price_multiplier == decimal.Decimal("0.02") assert mode.use_stop_loss is True assert mode.stop_loss_price_multiplier == decimal.Decimal("0.1") async def test_inner_start(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) with mock.patch.object(producer, "dca_task", mock.AsyncMock()) as dca_task_mock, \ mock.patch.object(producer, "get_channels_registration", mock.Mock(return_value=[])): # evaluator based mode.trigger_mode = dca_trading.TriggerMode.MAXIMUM_EVALUATORS_SIGNALS_BASED await producer.inner_start() for _ in range(10): await asyncio_tools.wait_asyncio_next_cycle() dca_task_mock.assert_not_called() # time based mode.trigger_mode = dca_trading.TriggerMode.TIME_BASED await producer.inner_start() for _ in range(10): await asyncio_tools.wait_asyncio_next_cycle() dca_task_mock.assert_called_once() async def test_dca_task(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) calls = [] try: def _on_trigger(**kwargs): if len(calls): # now stop producer.should_stop = True calls.append(kwargs) producer.exchange_manager.is_backtesting = True with mock.patch.object(asyncio, "sleep", mock.AsyncMock()) as sleep_mock: # backtesting: trigger only once with mock.patch.object(producer, "trigger_dca", mock.AsyncMock(side_effect=_on_trigger)) as trigger_dca_mock: await producer.dca_task() assert trigger_dca_mock.call_count == 1 assert trigger_dca_mock.mock_calls[0].kwargs == { "cryptocurrency": "Bitcoin", "symbol": "BTC/USDT", "state": trading_enums.EvaluatorStates.VERY_LONG } sleep_mock.assert_not_called() calls.clear() # live: loop trigger producer.exchange_manager.is_backtesting = False with mock.patch.object(producer, "trigger_dca", mock.AsyncMock(side_effect=_on_trigger)) as trigger_dca_mock: await producer.dca_task() assert trigger_dca_mock.call_count == 2 assert trigger_dca_mock.mock_calls[0].kwargs == { "cryptocurrency": "Bitcoin", "symbol": "BTC/USDT", "state": trading_enums.EvaluatorStates.VERY_LONG } assert sleep_mock.call_count == 2 assert sleep_mock.mock_calls[0].args == (10080 * commons_constants.MINUTE_TO_SECONDS,) assert sleep_mock.mock_calls[1].args == (10080 * commons_constants.MINUTE_TO_SECONDS,) finally: producer.exchange_manager.is_backtesting = True async def test_trigger_dca(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object(producer, "_process_entries", mock.AsyncMock()) as _process_entries_mock, \ mock.patch.object(producer, "_process_exits", mock.AsyncMock()) as _process_exits_mock: producer.last_activity = None await producer.trigger_dca("crypto", "symbol", trading_enums.EvaluatorStates.NEUTRAL) assert producer.state is trading_enums.EvaluatorStates.NEUTRAL assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.NOTHING_TO_DO ) # neutral is not triggering anything _process_entries_mock.assert_not_called() _process_exits_mock.assert_not_called() producer.last_activity = None await producer.trigger_dca("crypto", "symbol", trading_enums.EvaluatorStates.LONG) assert producer.state is trading_enums.EvaluatorStates.LONG _process_entries_mock.assert_called_once_with("crypto", "symbol", trading_enums.EvaluatorStates.LONG) _process_exits_mock.assert_called_once_with("crypto", "symbol", trading_enums.EvaluatorStates.LONG) assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) _process_entries_mock.reset_mock() _process_exits_mock.reset_mock() producer.last_activity = None await producer.trigger_dca("crypto", "symbol", trading_enums.EvaluatorStates.VERY_SHORT) assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) assert producer.state is trading_enums.EvaluatorStates.VERY_SHORT _process_entries_mock.assert_called_once_with("crypto", "symbol", trading_enums.EvaluatorStates.VERY_SHORT) _process_exits_mock.assert_called_once_with("crypto", "symbol", trading_enums.EvaluatorStates.VERY_SHORT) async def test_process_entries(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object(producer, "submit_trading_evaluation", mock.AsyncMock()) as submit_trading_evaluation_mock, \ mock.patch.object(producer, "cancel_symbol_open_orders", mock.AsyncMock()) as cancel_symbol_open_orders_mock, \ mock.patch.object(producer, "_send_alert_notification", mock.AsyncMock()) as _send_alert_notification_mock, \ mock.patch.object(trader.exchange_manager.exchange_personal_data.positions_manager, "get_symbol_position", mock.AsyncMock()) as get_symbol_position_mock: await producer._process_entries("crypto", "symbol", trading_enums.EvaluatorStates.NEUTRAL) # neutral state: does not create orders submit_trading_evaluation_mock.assert_not_called() cancel_symbol_open_orders_mock.assert_not_called() _send_alert_notification_mock.assert_not_called() # spot trading: get_symbol_position is not called by _process_pre_entry_actions get_symbol_position_mock.assert_not_called() await producer._process_entries("crypto", "symbol", trading_enums.EvaluatorStates.SHORT) await producer._process_entries("crypto", "symbol", trading_enums.EvaluatorStates.VERY_SHORT) # short state: not yet supported submit_trading_evaluation_mock.assert_not_called() _send_alert_notification_mock.assert_not_called() get_symbol_position_mock.assert_not_called() for state in (trading_enums.EvaluatorStates.LONG, trading_enums.EvaluatorStates.VERY_LONG): await producer._process_entries("crypto", "symbol", state) # short state: not yet supported submit_trading_evaluation_mock.assert_called_once_with( cryptocurrency="crypto", symbol="symbol", time_frame=None, final_note=None, state=state ) get_symbol_position_mock.assert_not_called() _send_alert_notification_mock.assert_called_once_with("symbol", state, "entry") _send_alert_notification_mock.reset_mock() submit_trading_evaluation_mock.reset_mock() async def test_get_channels_registration(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.trigger_mode = dca_trading.TriggerMode.TIME_BASED assert producer.get_channels_registration() == [] mode.trigger_mode = dca_trading.TriggerMode.MAXIMUM_EVALUATORS_SIGNALS_BASED assert producer.get_channels_registration() == [ producer.TOPIC_TO_CHANNEL_NAME[commons_enum.ActivationTopics.EVALUATION_CYCLE.value] ] async def _process_exits(tools): # not implemented update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object(producer, "submit_trading_evaluation", mock.AsyncMock()) as submit_trading_evaluation_mock: for state in trading_enums.EvaluatorStates: await producer._process_exits("crypto", "symbol", state) submit_trading_evaluation_mock.assert_not_called() async def test_split_entry_quantity(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) symbol = mode.symbol symbol_market = trader.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) assert consumer._split_entry_quantity( decimal.Decimal("123"), 1, decimal.Decimal("12"), decimal.Decimal("15"), symbol_market ) == [(1, decimal.Decimal("123"))] assert consumer._split_entry_quantity( decimal.Decimal("123"), 2, decimal.Decimal("12"), decimal.Decimal("15"), symbol_market ) == [(1, decimal.Decimal("61.5")), (2, decimal.Decimal("61.5"))] assert consumer._split_entry_quantity( decimal.Decimal("123"), 3, decimal.Decimal("12"), decimal.Decimal("15"), symbol_market ) == [(1, decimal.Decimal("41")), (2, decimal.Decimal("41")), (3, decimal.Decimal("41"))] # not enough for 3 orders, do 1 assert consumer._split_entry_quantity( decimal.Decimal("0.0001"), 3, decimal.Decimal("12"), decimal.Decimal("15"), symbol_market ) == [(1, decimal.Decimal('0.0001'))] # not enough for 3 orders, do 0 assert consumer._split_entry_quantity( decimal.Decimal("0.000001"), 3, decimal.Decimal("12"), decimal.Decimal("15"), symbol_market ) == [] async def test_create_entry_with_chained_exit_orders(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.stop_loss_price_multiplier = decimal.Decimal("0.12") mode.exit_limit_orders_price_multiplier = decimal.Decimal("0.07") mode.secondary_exit_orders_price_multiplier = decimal.Decimal("0.035") mode.secondary_exit_orders_count = 0 symbol = mode.symbol symbol_market = trader.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) entry_price = decimal.Decimal("1222") entry_order = trading_personal_data.create_order_instance( trader=trader, order_type=trading_enums.TraderOrderType.BUY_LIMIT, symbol=symbol, current_price=entry_price, quantity=decimal.Decimal("3"), price=entry_price ) with mock.patch.object(mode, "create_order", mock.AsyncMock(side_effect=lambda *args, **kwargs: args[0])) \ as create_order_mock: # no chained stop loss # no take profit mode.use_stop_loss = False mode.use_take_profit_exit_orders = False await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) assert entry_order.chained_orders == [] # reset values create_order_mock.reset_mock() entry_order.chained_orders = [] # chained stop loss # no take profit # with dependencies mode.use_stop_loss = True mode.use_take_profit_exit_orders = False dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, dependencies) create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=dependencies) assert len(entry_order.chained_orders) == 1 stop_loss = entry_order.chained_orders[0] assert isinstance(stop_loss, trading_personal_data.StopLossOrder) assert isinstance(stop_loss.state, trading_personal_data.PendingCreationChainedOrderState) assert stop_loss.symbol == entry_order.symbol assert stop_loss.origin_quantity == entry_order.origin_quantity assert stop_loss.origin_price == entry_price * (1 - mode.stop_loss_price_multiplier) assert stop_loss.triggered_by is entry_order assert stop_loss.order_group is None assert stop_loss.reduce_only is False assert stop_loss.update_with_triggering_order_fees is True # reset values create_order_mock.reset_mock() entry_order.chained_orders = [] # no chained stop loss # take profit mode.use_stop_loss = False mode.use_take_profit_exit_orders = True await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) create_order_mock.reset_mock() assert len(entry_order.chained_orders) == 1 take_profit = entry_order.chained_orders[0] assert isinstance(take_profit, trading_personal_data.SellLimitOrder) assert isinstance(take_profit.state, trading_personal_data.PendingCreationChainedOrderState) assert take_profit.symbol == entry_order.symbol assert take_profit.origin_quantity == entry_order.origin_quantity assert take_profit.origin_price == entry_price * (1 + mode.exit_limit_orders_price_multiplier) assert take_profit.triggered_by is entry_order assert take_profit.order_group is None assert take_profit.reduce_only is False assert take_profit.update_with_triggering_order_fees is True # reset values create_order_mock.reset_mock() entry_order.chained_orders = [] # chained stop loss # take profit mode.use_stop_loss = True mode.use_take_profit_exit_orders = True await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) create_order_mock.reset_mock() assert len(entry_order.chained_orders) == 2 stop_loss = entry_order.chained_orders[0] take_profit = entry_order.chained_orders[1] assert stop_loss.origin_quantity == entry_order.origin_quantity assert take_profit.origin_quantity == entry_order.origin_quantity assert isinstance(stop_loss, trading_personal_data.StopLossOrder) assert isinstance(stop_loss.state, trading_personal_data.PendingCreationChainedOrderState) assert isinstance(take_profit, trading_personal_data.SellLimitOrder) assert isinstance(take_profit.state, trading_personal_data.PendingCreationChainedOrderState) assert take_profit.order_group is stop_loss.order_group assert isinstance(take_profit.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) assert take_profit.update_with_triggering_order_fees is True assert take_profit.is_active assert stop_loss.is_active # reset values create_order_mock.reset_mock() entry_order.chained_orders = [] # with inactive orders # chained stop loss # 3 take profit (initial + 2 additional) trader.enable_inactive_orders = True mode.use_stop_loss = True mode.use_take_profit_exit_orders = True mode.use_secondary_exit_orders = True mode.secondary_exit_orders_count = 2 await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) create_order_mock.reset_mock() assert len(entry_order.chained_orders) == 2 * 3 # 3 stop loss & take profits couples stop_losses = [ order for order in entry_order.chained_orders if isinstance(order, trading_personal_data.StopLossOrder) ] take_profits = [ order for order in entry_order.chained_orders if isinstance(order, trading_personal_data.SellLimitOrder) ] # ensure only stop losses and take profits in chained orders assert len(entry_order.chained_orders) == len(stop_losses) + len(take_profits) total_stop_quantity = trading_constants.ZERO total_tp_quantity = trading_constants.ZERO previous_stop_price = entry_price previous_tp_price = trading_constants.ZERO for i, (stop_loss, take_profit) in enumerate(zip(stop_losses, take_profits)): is_last = i == len(stop_losses) - 1 assert isinstance(stop_loss.state, trading_personal_data.PendingCreationChainedOrderState) assert isinstance(take_profit.state, trading_personal_data.PendingCreationChainedOrderState) total_tp_quantity += take_profit.origin_quantity total_stop_quantity += stop_loss.origin_quantity # constant price with stop losses if not previous_tp_price: previous_stop_price = stop_loss.origin_price else: assert stop_loss.origin_price == previous_stop_price # increasing price with take profits assert take_profit.origin_price > previous_tp_price previous_tp_price = take_profit.origin_price # ensure orders are grouped together assert take_profit.order_group is stop_loss.order_group assert isinstance(take_profit.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) assert stop_loss.update_with_triggering_order_fees is is_last assert take_profit.update_with_triggering_order_fees is is_last assert take_profit.is_active is False # TP are inactive assert stop_loss.is_active is True # SL are active # ensure selling the total entry quantity assert total_stop_quantity == entry_order.origin_quantity assert total_tp_quantity == entry_order.origin_quantity # reset values create_order_mock.reset_mock() entry_order.chained_orders = [] # chained stop loss on futures consumer.exchange_manager.is_future = True # 3 take profit (initial + 2 additional) mode.use_stop_loss = True mode.use_take_profit_exit_orders = True # disable use_secondary_exit_orders mode.use_secondary_exit_orders = False mode.secondary_exit_orders_count = 2 # disabled await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) create_order_mock.reset_mock() assert len(entry_order.chained_orders) == 2 # 1 take profit and one stop loss: no secondary exit is allowed stop_losses = [ order for order in entry_order.chained_orders if isinstance(order, trading_personal_data.StopLossOrder) ] take_profits = [ order for order in entry_order.chained_orders if isinstance(order, trading_personal_data.SellLimitOrder) ] # ensure only stop losses and take profits in chained orders assert len(stop_losses) == 1 assert len(take_profits) == 1 assert all(o.is_active is True for o in entry_order.chained_orders) # on futures, all orders are active assert all(order.reduce_only is True for order in entry_order.chained_orders) # futures: use reduce only assert stop_losses[0].origin_quantity == take_profits[0].origin_quantity == entry_order.origin_quantity # update_with_triggering_order_fees is false because we are trading futures assert stop_losses[0].update_with_triggering_order_fees == take_profits[0].update_with_triggering_order_fees == False async def test_skip_create_entry_order_when_too_many_live_exit_orders(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.stop_loss_price_multiplier = decimal.Decimal("0.12") mode.exit_limit_orders_price_multiplier = decimal.Decimal("0.07") mode.secondary_exit_orders_price_multiplier = decimal.Decimal("0.035") mode.secondary_exit_orders_count = 0 symbol = mode.symbol symbol_market = trader.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) entry_price = decimal.Decimal("1222") entry_order = trading_personal_data.create_order_instance( trader=trader, order_type=trading_enums.TraderOrderType.BUY_LIMIT, symbol=symbol, current_price=entry_price, quantity=decimal.Decimal("3"), price=entry_price ) with mock.patch.object(mode, "create_order", mock.AsyncMock(side_effect=lambda *args, **kwargs: args[0])) \ as create_order_mock, \ mock.patch.object(trader.exchange_manager.exchange, "get_max_orders_count", mock.Mock(return_value=1)) \ as get_max_orders_count_mock, \ mock.patch.object( trader.exchange_manager.exchange_personal_data.orders_manager, "get_open_orders", mock.Mock(return_value=[mock.Mock(order_type=trading_enums.TraderOrderType.BUY_LIMIT)]) ) as get_open_orders_mock: # 1.A can't even create entry limit order mode.use_stop_loss = False mode.use_take_profit_exit_orders = False with pytest.raises(octobot_trading.errors.MaxOpenOrderReachedForSymbolError): await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) assert get_max_orders_count_mock.call_count == 1 * 2 # entry: 1, stop: 0, tp: 0, 2 calls for each get_max_orders_count_mock.reset_mock() create_order_mock.assert_not_called() get_open_orders_mock.assert_called_once() # 1.B can create entry market order entry_order.order_type=trading_enums.TraderOrderType.BUY_MARKET assert await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) get_max_orders_count_mock.assert_not_called() # not called for entry marker orders create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) create_order_mock.reset_mock() assert len(entry_order.chained_orders) == 0 get_max_orders_count_mock.reset_mock() create_order_mock.assert_not_called() get_open_orders_mock.assert_called_once() # restore order type entry_order.order_type=trading_enums.TraderOrderType.BUY_LIMIT with mock.patch.object(mode, "create_order", mock.AsyncMock(side_effect=lambda *args, **kwargs: args[0])) \ as create_order_mock, \ mock.patch.object(trader.exchange_manager.exchange, "get_max_orders_count", mock.Mock(return_value=1)) \ as get_max_orders_count_mock: mode.use_stop_loss = True mode.use_take_profit_exit_orders = True # 2. chained stop loss & take profit assert await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) assert get_max_orders_count_mock.call_count == 3 * 2 # entry: 1, stop: 1, tp: 1, 2 calls for each get_max_orders_count_mock.reset_mock() create_order_mock.assert_called_once_with(entry_order, params=None, dependencies=None) create_order_mock.reset_mock() assert len(entry_order.chained_orders) == 2 stop_loss = entry_order.chained_orders[0] take_profit = entry_order.chained_orders[1] assert isinstance(stop_loss, trading_personal_data.StopLossOrder) assert isinstance(take_profit, trading_personal_data.SellLimitOrder) # 3. chained stop loss & take profit: impossible: cancel whole entry mode.use_secondary_exit_orders = True mode.secondary_exit_orders_count = 1 with pytest.raises(octobot_trading.errors.MaxOpenOrderReachedForSymbolError): await consumer._create_entry_with_chained_exit_orders(entry_order, entry_price, symbol_market, None) assert get_max_orders_count_mock.call_count == 4 * 2 # entry: 1, stop: 1, tp: 2, 2 calls for each get_max_orders_count_mock.reset_mock() create_order_mock.assert_not_called() async def test_create_entry_order(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) symbol = mode.symbol symbol_market = trader.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) price = decimal.Decimal("1222") order_type = trading_enums.TraderOrderType.BUY_LIMIT quantity = decimal.Decimal("42") current_price = decimal.Decimal("22222") with mock.patch.object( consumer, "_create_entry_with_chained_exit_orders", mock.AsyncMock(return_value=None) ) as _create_entry_with_chained_exit_orders_mock: created_orders = [] assert await consumer._create_entry_order( order_type, quantity, price, symbol_market, symbol, created_orders, current_price, dependencies=None ) is False _create_entry_with_chained_exit_orders_mock.assert_called_once() assert _create_entry_with_chained_exit_orders_mock.mock_calls[0].args[3] == None assert created_orders == [] with mock.patch.object( consumer, "_create_entry_with_chained_exit_orders", mock.AsyncMock( side_effect=octobot_trading.errors.MaxOpenOrderReachedForSymbolError ) ) as _create_entry_with_chained_exit_orders_mock: created_orders = [] assert await consumer._create_entry_order( order_type, quantity, price, symbol_market, symbol, created_orders, current_price, dependencies=None ) is False _create_entry_with_chained_exit_orders_mock.assert_called_once() assert _create_entry_with_chained_exit_orders_mock.mock_calls[0].args[3] == None assert created_orders == [] with mock.patch.object( consumer, "_create_entry_with_chained_exit_orders", mock.AsyncMock(return_value="created_order") ) as _create_entry_with_chained_exit_orders_mock: created_orders = [] assert await consumer._create_entry_order( order_type, quantity, price, symbol_market, symbol, created_orders, current_price, dependencies=None ) is True _create_entry_with_chained_exit_orders_mock.assert_called_once() created_order = _create_entry_with_chained_exit_orders_mock.mock_calls[0].args[0] assert created_order.order_type == order_type assert created_order.origin_quantity == quantity assert created_order.origin_price == price assert created_order.symbol == symbol assert created_order.created_last_price == current_price assert created_orders == ["created_order"] async def test_create_entry_order_with_max_ratio(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) symbol = mode.symbol symbol_market = trader.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) price = decimal.Decimal("1222") order_type = trading_enums.TraderOrderType.BUY_LIMIT quantity = decimal.Decimal("42") current_price = decimal.Decimal("22222") created_orders = [] with mock.patch.object( consumer, "_create_entry_with_chained_exit_orders", mock.AsyncMock(return_value="created_order") ) as _create_entry_with_chained_exit_orders_mock: with mock.patch.object( consumer, "_is_max_asset_ratio_reached", mock.Mock(return_value=True) ) as _is_max_asset_ratio_reached_mock: dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) assert await consumer._create_entry_order( order_type, quantity, price, symbol_market, symbol, created_orders, current_price, dependencies=dependencies ) is False _is_max_asset_ratio_reached_mock.assert_called_with(symbol) _create_entry_with_chained_exit_orders_mock.assert_not_called() with mock.patch.object( consumer, "_is_max_asset_ratio_reached", mock.Mock(return_value=False) ) as _is_max_asset_ratio_reached_mock: dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) assert await consumer._create_entry_order( order_type, quantity, price, symbol_market, symbol, created_orders, current_price, dependencies=dependencies ) is True _is_max_asset_ratio_reached_mock.assert_called_with(symbol) _create_entry_with_chained_exit_orders_mock.assert_called_once() assert _create_entry_with_chained_exit_orders_mock.mock_calls[0].args[3] == dependencies async def test_create_create_order_if_possible_with_funds_already_locked(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) symbol = mode.symbol with mock.patch.object( consumer, "create_new_orders", mock.AsyncMock(return_value=["orders"]) ) as create_new_orders_mock: # case 1: all OK => enough available funds trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].available = \ decimal.Decimal("1000") # DOES NOT cancel orders before creating entries: can new entries as funds are available mode.cancel_open_orders_at_each_entry = False assert await consumer.create_order_if_possible(symbol, None, trading_enums.EvaluatorStates.VERY_LONG.value) == ["orders"] create_new_orders_mock.assert_called_once() create_new_orders_mock.reset_mock() # DOES cancel orders before creating entries: can create new entries as funds are available mode.cancel_open_orders_at_each_entry = True assert await consumer.create_order_if_possible(symbol, None, trading_enums.EvaluatorStates.VERY_LONG.value) == ["orders"] create_new_orders_mock.assert_called_once() create_new_orders_mock.reset_mock() # case 2: NOT all OK => not enough available funds trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].available = \ decimal.Decimal("0") # DOES NOT cancel orders before creating entries: can't create new entries when no funds are available mode.cancel_open_orders_at_each_entry = False assert await consumer.create_order_if_possible(symbol, None, trading_enums.EvaluatorStates.VERY_LONG.value) == [] create_new_orders_mock.assert_not_called() # DOES cancel orders before creating entries: can create new entries when no funds are available mode.cancel_open_orders_at_each_entry = True assert await consumer.create_order_if_possible(symbol, None, trading_enums.EvaluatorStates.VERY_LONG.value) == ["orders"] create_new_orders_mock.assert_called_once() async def test_is_max_asset_ratio_reached(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) assert mode.max_asset_holding_ratio == trading_constants.ONE symbol = "BTC/USDT" base = "BTC" portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(return_value=decimal.Decimal("1")) ) as get_holdings_ratio_mock: assert consumer._is_max_asset_ratio_reached(symbol) is True get_holdings_ratio_mock.assert_called_with(base, include_assets_in_open_orders=True) with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(return_value=decimal.Decimal("0.4")) ) as get_holdings_ratio_mock: assert consumer._is_max_asset_ratio_reached(symbol) is False get_holdings_ratio_mock.assert_called_with(base, include_assets_in_open_orders=True) get_holdings_ratio_mock.reset_mock() mode.max_asset_holding_ratio = decimal.Decimal("0.4") assert consumer._is_max_asset_ratio_reached(symbol) is True get_holdings_ratio_mock.assert_called_with(base, include_assets_in_open_orders=True) get_holdings_ratio_mock.reset_mock() mode.max_asset_holding_ratio = decimal.Decimal("0.41") assert consumer._is_max_asset_ratio_reached(symbol) is False get_holdings_ratio_mock.assert_called_with(base, include_assets_in_open_orders=True) get_holdings_ratio_mock.reset_mock() # disabled on futures consumer.exchange_manager.is_future = True assert consumer._is_max_asset_ratio_reached(symbol) is False get_holdings_ratio_mock.assert_not_called() async def test_create_new_orders(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.secondary_entry_orders_count = 0 symbol = mode.symbol def _create_basic_order(side, quantity=decimal.Decimal("0.1")): created_order = trading_personal_data.Order(trader) created_order.symbol = symbol created_order.side = side created_order.origin_quantity = quantity created_order.origin_price = decimal.Decimal("1000") return created_order async def _create_entry_order(_, __, ___, ____, _____, created_orders, ______, dependencies): created_order = _create_basic_order(trading_enums.TradeOrderSide.BUY) created_orders.append(created_order) return bool(created_order) with mock.patch.object( consumer, "_create_entry_order", mock.AsyncMock(side_effect=_create_entry_order) ) as _create_entry_order_mock, mock.patch.object( mode, "cancel_order", mock.AsyncMock(return_value=(True, trading_signals.get_order_dependency(mock.Mock(order_id="456")))) ) as cancel_order_mock: # neutral state assert await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.NEUTRAL.value) == [] cancel_order_mock.assert_not_called() _create_entry_order_mock.assert_not_called() # no configured amount mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "" assert await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) == [] cancel_order_mock.assert_not_called() _create_entry_order_mock.assert_not_called() # no configured secondary amount mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "12%" mode.secondary_entry_orders_amount = "" dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value, dependencies=dependencies) cancel_order_mock.assert_not_called() _create_entry_order_mock.assert_called_once() assert _create_entry_order_mock.mock_calls[0].args[7] == dependencies _create_entry_order_mock.reset_mock() # with secondary orders but no configured secondary amount mode.secondary_entry_orders_count = 4 await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) cancel_order_mock.assert_not_called() # only called once: missing secondary quantity prevents secondary orders creation _create_entry_order_mock.assert_called_once() _create_entry_order_mock.reset_mock() mode.use_market_entry_orders = False mode.use_secondary_entry_orders = True mode.secondary_entry_orders_amount = "20q" await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) cancel_order_mock.assert_not_called() # called as many times as there are orders to create assert _create_entry_order_mock.call_count == 1 + 4 # ensure each secondary order has a lower price previous_price = None for i, call in enumerate(_create_entry_order_mock.mock_calls): if i == 0: assert call.args[1] == decimal.Decimal('0.24') # initial quantity else: assert call.args[1] == decimal.Decimal('0.02') # secondary quantity assert call.args[0] is trading_enums.TraderOrderType.BUY_LIMIT call_price = call.args[2] if previous_price is None: previous_price = call_price else: assert call_price < previous_price _create_entry_order_mock.reset_mock() mode.use_market_entry_orders = True await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.VERY_LONG.value) cancel_order_mock.assert_not_called() # called as many times as there are orders to create assert _create_entry_order_mock.call_count == 1 + 4 for i, call in enumerate(_create_entry_order_mock.mock_calls): expected_type = trading_enums.TraderOrderType.BUY_MARKET \ if i == 0 else trading_enums.TraderOrderType.BUY_LIMIT assert call.args[0] is expected_type _create_entry_order_mock.reset_mock() # with existing orders locking all funds: cancel them all existing_orders = [ _create_basic_order(trading_enums.TradeOrderSide.BUY, decimal.Decimal(1)), _create_basic_order(trading_enums.TradeOrderSide.BUY, decimal.Decimal(1)), _create_basic_order(trading_enums.TradeOrderSide.SELL), ] for order in existing_orders: await trader.exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(order) assert trader.exchange_manager.exchange_personal_data.orders_manager.get_all_orders(symbol=symbol) == \ existing_orders # cancelled orders amounts are taken into account to consider entry orders creatable trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].available = \ decimal.Decimal("0") async def _cancel_order(order, dependencies=None): trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].available += \ order.origin_quantity * order.origin_price return True, trading_signals.get_orders_dependencies([mock.Mock(order_id="456")]) # with existing orders locking all funds: cancel non partially filled ones dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object( mode, "cancel_order", mock.AsyncMock(side_effect=_cancel_order) ) as cancel_order_mock_2: with mock.patch.object( trader.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol=symbol)[0], "is_partially_filled", mock.Mock(return_value=True) ) as is_partially_filled_mock: await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value, dependencies=dependencies) is_partially_filled_mock.assert_called_once() assert cancel_order_mock_2.call_count == 1 assert cancel_order_mock_2.mock_calls[0].args[0] == existing_orders[1] assert cancel_order_mock_2.mock_calls[0].kwargs["dependencies"] == dependencies cancel_order_mock_2.reset_mock() # called as many times as there are orders to create assert _create_entry_order_mock.call_count == 1 + 4 assert _create_entry_order_mock.mock_calls[0].args[7] == trading_signals.get_orders_dependencies([mock.Mock(order_id="456")]) _create_entry_order_mock.reset_mock() trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].available = \ trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].total # order 2 is now partially filled, it won't be cancelled dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object( mode, "cancel_order", mock.AsyncMock(side_effect=_cancel_order) ) as cancel_order_mock_2: await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value, dependencies=dependencies) assert cancel_order_mock_2.call_count == 2 assert cancel_order_mock_2.mock_calls[0].args[0] == existing_orders[0] assert cancel_order_mock_2.mock_calls[1].args[0] == existing_orders[1] assert cancel_order_mock_2.mock_calls[0].kwargs["dependencies"] == dependencies assert cancel_order_mock_2.mock_calls[1].kwargs["dependencies"] == dependencies cancel_order_mock_2.reset_mock() # called as many times as there are orders to create assert _create_entry_order_mock.call_count == 1 + 4 assert _create_entry_order_mock.mock_calls[0].args[7] == trading_signals.get_orders_dependencies([ mock.Mock(order_id="456"), mock.Mock(order_id="456")] ) _create_entry_order_mock.reset_mock() trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].available = \ trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USDT"].total # without enough funds to create every secondary order mode.secondary_entry_orders_count = 30 # can't create 30 orders, each using 100 USD of available funds await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value, dependencies=None) assert cancel_order_mock.call_count == 2 # still cancel open orders assert cancel_order_mock.mock_calls[0].args[0] == existing_orders[0] assert cancel_order_mock.mock_calls[1].args[0] == existing_orders[1] portfolio = trading_api.get_portfolio(trader.exchange_manager) order_example = _create_basic_order(trading_enums.TradeOrderSide.BUY) # ensure used all funds assert portfolio["USDT"].available / _create_entry_order_mock.call_count == \ order_example.origin_quantity * order_example.origin_price cancel_order_mock.reset_mock() # called as many times as there are orders to create # 10 orders out of 30 got skipped assert _create_entry_order_mock.call_count == 1 + 19 _create_entry_order_mock.reset_mock() with mock.patch.object(consumer, "_is_max_asset_ratio_reached", mock.Mock(return_value=True)) as \ _is_max_asset_ratio_reached_mock: async def _create_entry_order_2(_, __, ___, ____, _____, created_orders, ______, dependencies): created_order = _create_basic_order(trading_enums.TradeOrderSide.BUY) created_orders.append(created_order) # simulate no order created return False with mock.patch.object(consumer, "_create_entry_order", mock.Mock(side_effect=_create_entry_order_2)) as \ _create_entry_order_mock_2: # without enough funds to create every secondary order mode.secondary_entry_orders_count = 30 # can't create 30 orders, each using 100 USD of available funds await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) assert cancel_order_mock.call_count == 2 # still cancel open orders assert cancel_order_mock.mock_calls[0].args[0] == existing_orders[0] assert cancel_order_mock.mock_calls[1].args[0] == existing_orders[1] portfolio = trading_api.get_portfolio(trader.exchange_manager) order_example = _create_basic_order(trading_enums.TradeOrderSide.BUY) # did NOT ensure used all funds as only 1 secondary order is created # (_create_entry_order returned False) assert portfolio["USDT"].available / _create_entry_order_mock_2.call_count > \ order_example.origin_quantity * order_example.origin_price cancel_order_mock.reset_mock() # called as many times as there are orders to create # 1 orders out of 30 got created assert _create_entry_order_mock_2.call_count == 1 + 1 _create_entry_order_mock_2.reset_mock() # doesn't matter if _is_max_asset_ratio_reached returns True: orders are still cancelled _is_max_asset_ratio_reached_mock.assert_not_called() # invalid initial orders according to exchange rules: does not cancel existing orders mode.secondary_entry_orders_count = 0 mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "0.000001q" await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) # orders are not cancelled cancel_order_mock.assert_not_called() # still called once in case orders can be created anyway _create_entry_order_mock.assert_called_once() _create_entry_order_mock.reset_mock() # invalid secondary orders according to exchange rules: does not cancel existing orders mode.secondary_entry_orders_count = 1 mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "20q" mode.secondary_entry_orders_amount = "0.000001q" await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) # orders are not cancelled cancel_order_mock.assert_not_called() # still called once for initial entry and once for secondary in case orders can be created anyway assert _create_entry_order_mock.call_count == 2 _create_entry_order_mock.reset_mock() with mock.patch.object( trading_personal_data, "decimal_check_and_adapt_order_details_if_necessary", mock.Mock(return_value=[]) ) as decimal_check_and_adapt_order_details_if_necessary_mock: # without enough funds to create initial orders according to exchange rules: does not cancel existing orders mode.secondary_entry_orders_count = 0 mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "0.20q" await consumer.create_new_orders(symbol, None, trading_enums.EvaluatorStates.LONG.value) # orders are not cancelled cancel_order_mock.assert_not_called() # still called once in case orders can be created anyway _create_entry_order_mock.assert_called_once() decimal_check_and_adapt_order_details_if_necessary_mock.assert_called_once() async def test_create_new_orders_fully_used_portfolio(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.use_secondary_entry_orders = True mode.secondary_entry_orders_count = 1 mode.secondary_entry_orders_amount = "8%t" mode.use_market_entry_orders = False mode.cancel_open_orders_at_each_entry = False mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "8%t" mode.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol("DOGE/USDT"), commons_symbols.parse_symbol("LINK/USDT") ] portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio portfolio["USDT"].available = decimal.Decimal("79.98463886") portfolio["USDT"].total = decimal.Decimal("1000") portfolio.pop("USD", None) portfolio.pop("BTC", None) trading_api.force_set_mark_price(trader.exchange_manager, "DOGE/USDT", 0.06852) trading_api.force_set_mark_price(trader.exchange_manager, "LINK/USDT", 11.0096) converter = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter converter.update_last_price("DOGE/USDT", decimal.Decimal("0.06852")) converter.update_last_price("LINK/USDT", decimal.Decimal("11.0096")) def _get_market_status(symbol, **kwargs): # example from kucoin on 1st nov 2023 if symbol == "DOGE/USDT": return { 'limits': { 'amount': {'max': 10000000000.0, 'min': 10.0}, 'cost': {'max': 99999999.0, 'min': 0.1}, 'leverage': {'max': None, 'min': None}, 'price': {'max': None, 'min': None} }, 'precision': {'amount': 4, 'price': 5} } if symbol == "LINK/USDT": return { 'limits': { 'amount': {'max': 10000000000.0, 'min': 0.001}, 'cost': {'max': 99999999.0, 'min': 0.1}, 'leverage': {'max': None, 'min': None}, 'price': {'max': None, 'min': None} }, 'precision': {'amount': 4, 'price': 4} } async def _create_order(order, **kwargs): await order.initialize(is_from_exchange_data=True, enable_associated_orders_creation=False) return order with mock.patch.object( trader.exchange_manager.exchange, "get_market_status", mock.Mock(side_effect=_get_market_status) ) as get_market_status_mock, mock.patch.object( mode, "create_order", mock.AsyncMock(side_effect=_create_order) ) as create_order_mock: orders_1, orders_2 = await asyncio.gather( consumer.create_new_orders("DOGE/USDT", None, trading_enums.EvaluatorStates.LONG.value), consumer.create_new_orders("LINK/USDT", None, trading_enums.EvaluatorStates.LONG.value), ) assert orders_1 assert len(orders_1) == 1 get_market_status_mock.reset_mock() assert orders_2 assert len(orders_2) == 1 total_cost = orders_1[0].total_cost + orders_2[0].total_cost assert total_cost <= decimal.Decimal("79.98463886") async def test_create_new_buy_orders_fees_in_quote(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.use_secondary_entry_orders = True mode.secondary_entry_orders_count = 1 mode.secondary_entry_orders_amount = "8%t" mode.use_market_entry_orders = False mode.cancel_open_orders_at_each_entry = False mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "8%t" mode.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol("DOGE/USDT"), commons_symbols.parse_symbol("LINK/USDT") ] portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio portfolio["USDT"].available = decimal.Decimal("279.98463886") portfolio["USDT"].total = decimal.Decimal("1000") portfolio.pop("USD", None) portfolio.pop("BTC", None) trading_api.force_set_mark_price(trader.exchange_manager, "DOGE/USDT", 0.06852) trading_api.force_set_mark_price(trader.exchange_manager, "LINK/USDT", 11.0096) converter = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter converter.update_last_price("DOGE/USDT", decimal.Decimal("0.06852")) converter.update_last_price("LINK/USDT", decimal.Decimal("11.0096")) def _get_fees_currency(base, quote, order_type): # force quote fees return quote def _read_fees_from_config(fees): # use 20% fees fees[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value] = 0.2 fees[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value] = 0.2 fees[trading_enums.ExchangeConstantsMarketPropertyColumns.FEE.value] = 0.2 async def _create_order(order, **kwargs): await order.initialize(is_from_exchange_data=True, enable_associated_orders_creation=False) return order with mock.patch.object( mode, "create_order", mock.AsyncMock(side_effect=_create_order) ) as create_order_mock, mock.patch.object( trader.exchange_manager.exchange.connector, "_get_fees_currency", mock.Mock(side_effect=_get_fees_currency) ) as _get_fees_currency_mock, mock.patch.object( trader.exchange_manager.exchange.connector, "_read_fees_from_config", mock.Mock(side_effect=_read_fees_from_config) ) as _get_fees_currency_mock: orders_1, orders_2 = await asyncio.gather( consumer.create_new_orders("DOGE/USDT", None, trading_enums.EvaluatorStates.LONG.value), consumer.create_new_orders("LINK/USDT", None, trading_enums.EvaluatorStates.LONG.value), ) assert orders_1 assert len(orders_1) == 2 assert orders_2 assert len(orders_2) == 1 # secondary order skipped because not enough funds after fees account total_cost = orders_1[0].total_cost + orders_1[1].total_cost + orders_2[0].total_cost assert total_cost <= decimal.Decimal("225.98463886") # took fees into account async def test_create_new_buy_orders_futures_trading(futures_tools): update = {} mode, producer, consumer, trader = await _init_mode(futures_tools, _get_config(futures_tools, update)) mode.use_secondary_entry_orders = True mode.secondary_entry_orders_count = 3 mode.secondary_entry_orders_amount = "8%t" mode.use_market_entry_orders = False mode.cancel_open_orders_at_each_entry = False mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "8%t" mode.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol("BTC/USDT:USDT") ] portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio portfolio["USDT"].available = decimal.Decimal("200") portfolio["USDT"].total = decimal.Decimal("200") portfolio.pop("USD", None) portfolio.pop("BTC", None) trading_api.force_set_mark_price(trader.exchange_manager, "BTC/USDT:USDT", 11.0096) converter = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.value_converter converter.update_last_price("BTC/USDT:USDT", decimal.Decimal("11.0096")) def _get_fees_currency(base, quote, order_type): # force quote fees return quote def _read_fees_from_config(fees): # use 0.2% fees fees[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value] = 0.002 fees[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value] = 0.002 fees[trading_enums.ExchangeConstantsMarketPropertyColumns.FEE.value] = 0.002 async def _create_order(order, **kwargs): await order.initialize(is_from_exchange_data=True, enable_associated_orders_creation=False) return order with mock.patch.object( mode, "create_order", mock.AsyncMock(side_effect=_create_order) ) as create_order_mock, mock.patch.object( trader.exchange_manager.exchange.connector, "_get_fees_currency", mock.Mock(side_effect=_get_fees_currency) ) as _get_fees_currency_mock, mock.patch.object( trader.exchange_manager.exchange.connector, "_read_fees_from_config", mock.Mock(side_effect=_read_fees_from_config) ) as _get_fees_currency_mock: orders = await consumer.create_new_orders("BTC/USDT:USDT", None, trading_enums.EvaluatorStates.LONG.value) assert orders assert len(orders) == 4 total_cost = sum(order.total_cost for order in orders) assert round(total_cost) == decimal.Decimal("56") async def test_create_set_leverage_on_futures_trading(futures_tools): update = {} mode, producer, consumer, trader = await _init_mode(futures_tools, _get_config(futures_tools, update)) mode.use_secondary_entry_orders = True mode.secondary_entry_orders_count = 3 mode.secondary_entry_orders_amount = "8%t" mode.use_market_entry_orders = False mode.cancel_open_orders_at_each_entry = False mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = "8%t" with mock.patch.object(mode, "set_leverage", mock.AsyncMock()) as set_leverage_mock, \ mock.patch.object(producer, "submit_trading_evaluation", mock.AsyncMock()) as submit_trading_evaluation: await producer._process_entries("Bitcoin", "BTC", trading_enums.EvaluatorStates.SHORT) # nothing happens on short set_leverage_mock.assert_not_called() submit_trading_evaluation.assert_not_called() await producer._process_entries("Bitcoin", "BTC/USDT:USDT", trading_enums.EvaluatorStates.LONG) # leverage config is not set set_leverage_mock.assert_not_called() submit_trading_evaluation.assert_called_once() submit_trading_evaluation.reset_mock() # now updated leverage mode.trading_config[trading_constants.CONFIG_LEVERAGE] = 4 await producer._process_entries("Bitcoin", "BTC/USDT:USDT", trading_enums.EvaluatorStates.LONG) set_leverage_mock.assert_called_once_with( "BTC/USDT:USDT", trading_enums.PositionSide.BOTH, decimal.Decimal(4) ) submit_trading_evaluation.assert_called_once() set_leverage_mock.reset_mock() submit_trading_evaluation.reset_mock() # don't update leverage when position already has the right leverage mode.trading_config[trading_constants.CONFIG_LEVERAGE] = 1 await producer._process_entries("Bitcoin", "BTC/USDT:USDT", trading_enums.EvaluatorStates.LONG) set_leverage_mock.assert_not_called() submit_trading_evaluation.assert_called_once() set_leverage_mock.reset_mock() submit_trading_evaluation.reset_mock() async def test_single_exchange_process_optimize_initial_portfolio(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object( octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=["order_1"]) ) as convert_assets_to_target_asset_mock: orders = await mode.single_exchange_process_optimize_initial_portfolio(["BTC", "ETH"], "USDT", {}) convert_assets_to_target_asset_mock.assert_called_once_with(mode, ["BTC", "ETH"], "USDT", {}) assert orders == ["order_1"] convert_assets_to_target_asset_mock.reset_mock() mode.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol("SOL/USDT"), commons_symbols.parse_symbol("BCC/ATOM"), ] orders = await mode.single_exchange_process_optimize_initial_portfolio(["BTC", "ETH"], "USDT", {}) convert_assets_to_target_asset_mock.assert_called_once_with( mode, ["BCC", "BTC", "ETH", "SOL"], "USDT", {} ) assert orders == ["order_1"] async def test_single_exchange_process_health_check(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) exchange_manager = trader.exchange_manager with mock.patch.object(producer, "dca_task", mock.AsyncMock()): # prevent auto dca task portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio converter = trader.exchange_manager.exchange_personal_data.portfolio_manager.\ portfolio_value_holder.value_converter converter.update_last_price(mode.symbol, decimal.Decimal("1000")) origin_portfolio_USDT = portfolio["USDT"].total # no traded symbols: no orders exchange_manager.exchange_config.traded_symbols = [] producer.last_activity = None assert await mode.single_exchange_process_health_check([], {}) == [] assert portfolio["USDT"].total == origin_portfolio_USDT assert producer.last_activity is None # with traded symbols: 1 order as BTC is not already in a sell order exchange_manager.exchange_config.traded_symbols = [commons_symbols.parse_symbol(mode.symbol)] # no self.use_take_profit_exit_orders or self.use_stop_loss mode.use_take_profit_exit_orders = False mode.use_stop_loss = False assert await mode.single_exchange_process_health_check([], {}) == [] assert producer.last_activity is None # no health check in backtesting exchange_manager.is_backtesting = True assert await mode.single_exchange_process_health_check([], {}) == [] assert producer.last_activity is None exchange_manager.is_backtesting = False # use_take_profit_exit_orders is True: health check can proceed mode.use_take_profit_exit_orders = True orders = await mode.single_exchange_process_health_check([], {}) assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) assert len(orders) == 1 sell_order = orders[0] assert isinstance(sell_order, trading_personal_data.SellMarketOrder) assert sell_order.symbol == mode.symbol assert sell_order.origin_quantity == decimal.Decimal(10) assert portfolio["BTC"].total == trading_constants.ZERO after_btc_usdt_portfolio = portfolio["USDT"].total assert after_btc_usdt_portfolio > origin_portfolio_USDT # now that BTC is sold, calling it again won't create any order producer.last_activity = None assert await mode.single_exchange_process_health_check([], {}) == [] assert producer.last_activity is None # add ETH in portfolio: will also be sold but is bellow threshold converter.update_last_price("ETH/USDT", decimal.Decimal("100")) exchange_manager.client_symbols.append("ETH/USDT") exchange_manager.exchange_config.traded_symbols.append(commons_symbols.parse_symbol("ETH/USDT")) eth_holdings = decimal.Decimal(2) portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings) producer.last_activity = None assert await mode.single_exchange_process_health_check([], {}) == [] assert producer.last_activity is None # more ETH: can sell but not all of it because of partially filled buy orders eth_holdings = decimal.Decimal(200) portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings) producer.last_activity = None buy_limit = trading_personal_data.BuyLimitOrder(trader) buy_limit.symbol = "ETH/USDT" buy_limit.origin_quantity = decimal.Decimal(200) buy_limit.filled_quantity = decimal.Decimal(199) with mock.patch.object( exchange_manager.exchange_personal_data.orders_manager, "get_open_orders", mock.Mock(return_value=[buy_limit]) ) as get_open_orders_mock: assert await mode.single_exchange_process_health_check([], {}) == [] assert get_open_orders_mock.call_count == 2 assert producer.last_activity is None # no partially filled buy orders: sell ETH eth_holdings = decimal.Decimal(200) portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings) producer.last_activity = None orders = await mode.single_exchange_process_health_check([], {}) assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) assert len(orders) == 1 sell_order = orders[0] assert isinstance(sell_order, trading_personal_data.SellMarketOrder) assert sell_order.symbol == "ETH/USDT" assert sell_order.origin_quantity == eth_holdings assert portfolio["ETH"].total == trading_constants.ZERO after_eth_usdt_portfolio = portfolio["USDT"].total assert after_eth_usdt_portfolio > after_btc_usdt_portfolio # add ETH to be sold but already in sell order: do not sell the part in sell orders eth_holdings = decimal.Decimal(200) portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings) existing_sell_order = trading_personal_data.SellLimitOrder(trader) existing_sell_order.origin_quantity = decimal.Decimal(45) existing_sell_order.symbol = "ETH/USDT" await exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(existing_sell_order) producer.last_activity = None orders = await mode.single_exchange_process_health_check([], {}) assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) assert len(orders) == 1 sell_order = orders[0] assert isinstance(sell_order, trading_personal_data.SellMarketOrder) assert sell_order.symbol == "ETH/USDT" assert sell_order.origin_quantity == eth_holdings - decimal.Decimal(45) assert portfolio["ETH"].total == decimal.Decimal(45) after_eth_usdt_portfolio = portfolio["USDT"].total assert after_eth_usdt_portfolio > after_btc_usdt_portfolio # add ETH to be sold but already in chained sell order: do not sell the part in chained sell orders eth_holdings = decimal.Decimal(200) portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings) chained_sell_order = trading_personal_data.SellLimitOrder(trader) chained_sell_order.origin_quantity = decimal.Decimal(10) chained_sell_order.symbol = "ETH/USDT" producer.last_activity = None orders = await mode.single_exchange_process_health_check([chained_sell_order], {}) assert producer.last_activity == octobot_trading.modes.TradingModeActivity( trading_enums.TradingModeActivityType.CREATED_ORDERS ) assert len(orders) == 1 sell_order = orders[0] assert isinstance(sell_order, trading_personal_data.SellMarketOrder) assert sell_order.symbol == "ETH/USDT" assert sell_order.origin_quantity == eth_holdings - decimal.Decimal(45) - decimal.Decimal(10) assert portfolio["ETH"].total == decimal.Decimal(45) + decimal.Decimal(10) after_eth_usdt_portfolio = portfolio["USDT"].total assert after_eth_usdt_portfolio > after_btc_usdt_portfolio # add ETH to be sold but already in chained sell order: do not sell the part in chained sell orders: # sell orders make it bellow threshold: no market sell created eth_holdings = decimal.Decimal(200) portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings) chained_sell_order = trading_personal_data.SellLimitOrder(trader) chained_sell_order.origin_quantity = decimal.Decimal(55) chained_sell_order.symbol = "ETH/USDT" producer.last_activity = None assert await mode.single_exchange_process_health_check([chained_sell_order], {}) == [] assert producer.last_activity is None async def _check_open_orders_count(trader, count): assert len(trading_api.get_open_orders(trader.exchange_manager)) == count async def _get_tools(symbol="BTC/USDT"): config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator( exchange_manager.config, exchange_manager, backtesting ) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.DCATradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol # trading mode is not initialized: to be initialized with the required config in tests # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) return mode, trader async def _get_futures_tools(symbol="BTC/USDT:USDT"): config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_spot_only = False exchange_manager.is_future = True exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator( exchange_manager.config, exchange_manager, backtesting ) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) contract = trading_exchange_data.FutureContract( pair=symbol, margin_type=trading_enums.MarginType.ISOLATED, contract_type=trading_enums.FutureContractType.LINEAR_PERPETUAL, current_leverage=trading_constants.ONE, maximum_leverage=trading_constants.ONE_HUNDRED ) exchange_manager.exchange.set_pair_future_contract(symbol, contract) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.DCATradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol # trading mode is not initialized: to be initialized with the required config in tests # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) return mode, trader async def _init_mode(tools, config): mode, trader = tools await mode.initialize(trading_config=config) return mode, mode.producers[0], mode.get_trading_mode_consumers()[0], trader async def _fill_order(order, trader, trigger_update_callback=True, ignore_open_orders=False, consumer=None, closed_orders_count=1): initial_len = len(trading_api.get_open_orders(trader.exchange_manager)) await order.on_fill(force_fill=True) if order.status == trading_enums.OrderStatus.FILLED: if not ignore_open_orders: assert len(trading_api.get_open_orders(trader.exchange_manager)) == initial_len - closed_orders_count if trigger_update_callback: await asyncio_tools.wait_asyncio_next_cycle() else: with mock.patch.object(consumer, "create_new_orders", new=mock.AsyncMock()): await asyncio_tools.wait_asyncio_next_cycle() async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/__init__.py ================================================ from .dip_analyser_trading import DipAnalyserTradingMode ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/config/DipAnalyserTradingMode.json ================================================ { "required_strategies": [ "DipAnalyserStrategyEvaluator" ], "sell_orders_count": 3, "stop_loss_multiplier": 0, "light_weight_price_multiplier": 1.04, "medium_weight_price_multiplier": 1.07, "heavy_weight_price_multiplier": 1.1, "light_weight_volume_multiplier": 0.5, "medium_weight_volume_multiplier": 0.7, "heavy_weight_volume_multiplier": 1, "ignore_exchange_fees": false, "emit_trading_signals": false, "trading_strategy": "" } ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/dip_analyser_trading.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import async_channel.constants as channel_constants import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.evaluators_util as evaluators_util import octobot_commons.symbols.symbol_util as symbol_util import octobot_evaluators.api as evaluators_api import octobot_evaluators.matrix as matrix import octobot_evaluators.enums as evaluators_enums import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.modes as trading_modes import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.errors as trading_errors import octobot_trading.personal_data as trading_personal_data import octobot_trading.modes.script_keywords as script_keywords import tentacles.Evaluator.Strategies as Strategies class DipAnalyserTradingMode(trading_modes.AbstractTradingMode): def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.sell_orders_per_buy = 3 def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ trading_modes.user_select_order_amount(self, inputs, include_sell=False) self.sell_orders_per_buy = self.UI.user_input( "sell_orders_count", commons_enums.UserInputTypes.INT, 3, inputs, min_val=1, title="Number of sell orders to create after each buy." ) self.UI.user_input( DipAnalyserTradingModeProducer.IGNORE_EXCHANGE_FEES, commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Ignore exchange fees when creating sell orders. When enabled, 100% of the bought assets will be " "sold, otherwise a small part will be kept to cover exchange fees." ) self.UI.user_input( DipAnalyserTradingModeConsumer.USE_BUY_MARKET_ORDERS, commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="Use market orders instead of limit orders upon buy signals. Using a market order makes will " "guaranty that each buy signal will create an entry. " "Limit orders (which are priced at 99.5% of the current price) " "can delay an entry for some time to replace an open buy order with a more suitable " "one when the market is very volatile. " "However limit orders might also never be filled and ending up missing a buy opportunity." ) self.UI.user_input( DipAnalyserTradingModeConsumer.STOP_LOSS_MULTIPLIER, commons_enums.UserInputTypes.FLOAT, 0, inputs, min_val=0, max_val=1, title="Stop loss price multiplier: ratio to compute the stop loss price. " "Example: a 0.7 multiplier on a 2000 USDT buy would create a " "stop price at 2000*0.7 = 1400 USDT. Leave at 0 to disable stop losses." ) self.UI.user_input( DipAnalyserTradingModeConsumer.LIGHT_VOLUME_WEIGHT, commons_enums.UserInputTypes.FLOAT, 0.4, inputs, min_val=0, max_val=1, title="Volume multiplier for a buy order on a light volume weight signal.", ) self.UI.user_input( DipAnalyserTradingModeConsumer.MEDIUM_VOLUME_WEIGHT, commons_enums.UserInputTypes.FLOAT, 0.7, inputs, min_val=0, max_val=1, title="Volume multiplier for a buy order on a medium volume weight signal.", ) self.UI.user_input( DipAnalyserTradingModeConsumer.HEAVY_VOLUME_WEIGHT, commons_enums.UserInputTypes.FLOAT, 1, inputs, min_val=0, max_val=1, title="Volume multiplier for a buy order on a heavy volume weight signal.", ) self.UI.user_input( DipAnalyserTradingModeConsumer.LIGHT_PRICE_WEIGHT, commons_enums.UserInputTypes.FLOAT, 1.04, inputs, min_val=1, title="Price multiplier for the top sell order in a light price weight signal.", ) self.UI.user_input( DipAnalyserTradingModeConsumer.MEDIUM_PRICE_WEIGHT, commons_enums.UserInputTypes.FLOAT, 1.07, inputs, min_val=1, title="Price multiplier for the top sell order in a medium price weight signal.", ) self.UI.user_input( DipAnalyserTradingModeConsumer.HEAVY_PRICE_WEIGHT, commons_enums.UserInputTypes.FLOAT, 1.1, inputs, min_val=1, title="Price multiplier for the top sell order in a heavy price weight signal.", ) @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_current_state(self) -> (str, float): return super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, \ "N/A" def get_mode_producer_classes(self) -> list: return [DipAnalyserTradingModeProducer] def get_mode_consumer_classes(self) -> list: return [DipAnalyserTradingModeConsumer] async def create_consumers(self) -> list: consumers = await super().create_consumers() # order consumer: filter by symbol not be triggered only on this symbol's orders order_consumer = await exchanges_channel.get_chan(trading_personal_data.OrdersChannel.get_name(), self.exchange_manager.id).new_consumer( self._order_notification_callback, symbol=self.symbol if self.symbol else channel_constants.CHANNEL_WILDCARD ) return consumers + [order_consumer] async def _order_notification_callback(self, exchange, exchange_id, cryptocurrency, symbol, order, update_type, is_from_bot): if order[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] \ == trading_enums.OrderStatus.FILLED.value and is_from_bot: await self.producers[0].order_filled_callback(order) @classmethod def get_is_symbol_wildcard(cls) -> bool: return False class DipAnalyserTradingModeConsumer(trading_modes.AbstractTradingModeConsumer): USE_BUY_MARKET_ORDERS = "use_buy_market_orders" STOP_LOSS_MULTIPLIER = "stop_loss_multiplier" STOP_LOSS_PRICE_MULTIPLIER = decimal.Decimal(0) USE_BUY_MARKET_ORDERS_VALUE = False LIMIT_PRICE_MULTIPLIER = decimal.Decimal("0.995") SOFT_MAX_CURRENCY_RATIO = decimal.Decimal("0.33") # consider a high ratio not to take too much risk and not to prevent order creation either DEFAULT_HOLDING_RATIO = decimal.Decimal("0.35") DEFAULT_FULL_VOLUME = decimal.Decimal("0.5") DEFAULT_SELL_TARGET = decimal.Decimal("1") RISK_VOLUME_MULTIPLIER = decimal.Decimal("0.2") DELTA_RATIO = decimal.Decimal("0.8") ORDER_ID_KEY = "order_id" VOLUME_KEY = "volume" BUY_PRICE_KEY = "buy_price" VOLUME_WEIGHT_KEY = "volume_weight" PRICE_WEIGHT_KEY = "price_weight" LIGHT_VOLUME_WEIGHT = "light_weight_volume_multiplier" MEDIUM_VOLUME_WEIGHT = "medium_weight_volume_multiplier" HEAVY_VOLUME_WEIGHT = "heavy_weight_volume_multiplier" VOLUME_WEIGH_TO_VOLUME_PERCENT = {} LIGHT_PRICE_WEIGHT = "light_weight_price_multiplier" MEDIUM_PRICE_WEIGHT = "medium_weight_price_multiplier" HEAVY_PRICE_WEIGHT = "heavy_weight_price_multiplier" PRICE_WEIGH_TO_PRICE_PERCENT = {} def __init__(self, trading_mode): super().__init__(trading_mode) self.sell_targets_by_order_id = {} def on_reload_config(self): """ Called at constructor and after the associated trading mode's reload_config. Implement if necessary """ self.STOP_LOSS_PRICE_MULTIPLIER = \ decimal.Decimal(f"{self.trading_mode.trading_config.get(self.STOP_LOSS_MULTIPLIER, 0)}") self.USE_BUY_MARKET_ORDERS_VALUE = self.trading_mode.trading_config.get(self.USE_BUY_MARKET_ORDERS, False) self.PRICE_WEIGH_TO_PRICE_PERCENT = {} self.PRICE_WEIGH_TO_PRICE_PERCENT[1] = \ decimal.Decimal(f"{self.trading_mode.trading_config[self.LIGHT_PRICE_WEIGHT]}") self.PRICE_WEIGH_TO_PRICE_PERCENT[2] = \ decimal.Decimal(f"{self.trading_mode.trading_config[self.MEDIUM_PRICE_WEIGHT]}") self.PRICE_WEIGH_TO_PRICE_PERCENT[3] = \ decimal.Decimal(f"{self.trading_mode.trading_config[self.HEAVY_PRICE_WEIGHT]}") self.VOLUME_WEIGH_TO_VOLUME_PERCENT[1] = \ decimal.Decimal(f"{self.trading_mode.trading_config[self.LIGHT_VOLUME_WEIGHT]}") self.VOLUME_WEIGH_TO_VOLUME_PERCENT[2] = \ decimal.Decimal(f"{self.trading_mode.trading_config[self.MEDIUM_VOLUME_WEIGHT]}") self.VOLUME_WEIGH_TO_VOLUME_PERCENT[3] = \ decimal.Decimal(f"{self.trading_mode.trading_config[self.HEAVY_VOLUME_WEIGHT]}") async def create_new_orders(self, symbol, final_note, state, **kwargs): timeout = kwargs.get("timeout", trading_constants.ORDER_DATA_FETCHING_TIMEOUT) data = kwargs.get("data", {}) if state == trading_enums.EvaluatorStates.LONG.value: volume_weight = data.get(self.VOLUME_WEIGHT_KEY, 1) price_weight = data.get(self.PRICE_WEIGHT_KEY, 1) return await self.create_buy_order(symbol, timeout, volume_weight, price_weight) elif state == trading_enums.EvaluatorStates.SHORT.value: quantity = data.get(self.VOLUME_KEY, decimal.Decimal("1")) buy_order_id = data[self.ORDER_ID_KEY] sell_weight = self._get_sell_target_for_registered_order(buy_order_id) sell_base = data[self.BUY_PRICE_KEY] return await self.create_sell_orders(symbol, timeout, self.trading_mode.sell_orders_per_buy, quantity, sell_weight, sell_base, buy_order_id) self.logger.error(f"Unknown required order action: data= {data}") async def create_buy_order(self, symbol, timeout, volume_weight, price_weight): current_order = None try: current_symbol_holding, current_market_holding, market_quantity, price, symbol_market = \ await trading_personal_data.get_pre_order_data(self.exchange_manager, symbol=symbol, timeout=timeout) max_buy_size = market_quantity if self.exchange_manager.is_future: max_buy_size, is_increasing_position = trading_personal_data.get_futures_max_order_size( self.exchange_manager, symbol, trading_enums.TradeOrderSide.BUY, price, False, current_symbol_holding, market_quantity ) base = symbol_util.parse_symbol(symbol).base created_orders = [] orders_should_have_been_created = False ctx = script_keywords.get_base_context(self.trading_mode, symbol) order_type = trading_enums.TraderOrderType.BUY_MARKET \ if self.USE_BUY_MARKET_ORDERS_VALUE else trading_enums.TraderOrderType.BUY_LIMIT quantity = await self._get_buy_quantity_from_weight(ctx, volume_weight, max_buy_size, base) limit_price = trading_personal_data.decimal_adapt_price( symbol_market, price if self.USE_BUY_MARKET_ORDERS_VALUE else self.get_limit_price(price) ) quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, symbol, order_type, quantity, limit_price, trading_enums.TradeOrderSide.BUY ) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, limit_price, symbol_market): orders_should_have_been_created = True current_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=symbol, current_price=price, quantity=order_quantity, price=order_price, ) if created_order := await self.trading_mode.create_order(current_order): created_orders.append(created_order) self._register_buy_order(created_order.order_id, price_weight) if created_orders: return created_orders if orders_should_have_been_created: raise trading_errors.OrderCreationError() raise trading_errors.MissingMinimalExchangeTradeVolume() except (trading_errors.MissingFunds, trading_errors.MissingMinimalExchangeTradeVolume, trading_errors.OrderCreationError, trading_errors.InvalidCancelPolicyError): raise except Exception as e: self.logger.exception( e, True, f"Failed to create order : {e}. Order: {current_order if current_order else None}" ) return [] async def create_sell_orders( self, symbol, timeout, sell_orders_count, quantity, sell_weight, sell_base, buy_order_id ): current_order = None try: reduce_only = False if self.exchange_manager.is_future and await self.wait_for_active_position(symbol, timeout): # can use reduce only orders now that the position is active reduce_only = True current_symbol_holding, current_market_holding, market_quantity, price, symbol_market = \ await trading_personal_data.get_pre_order_data(self.exchange_manager, symbol=symbol, timeout=timeout) max_sell_size = current_symbol_holding if self.exchange_manager.is_future: max_sell_size, is_increasing_position = trading_personal_data.get_futures_max_order_size( self.exchange_manager, symbol, trading_enums.TradeOrderSide.SELL, price, False, current_symbol_holding, market_quantity ) created_orders = [] orders_should_have_been_created = False sell_max_quantity = decimal.Decimal(min(decimal.Decimal(f"{max_sell_size}"), quantity)) to_create_orders = self._generate_sell_orders(sell_orders_count, sell_max_quantity, sell_weight, sell_base, symbol_market) for order_quantity, order_price in to_create_orders: orders_should_have_been_created = True current_limit_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=trading_enums.TraderOrderType.SELL_LIMIT, symbol=symbol, current_price=sell_base, quantity=order_quantity, price=order_price, reduce_only=reduce_only, associated_entry_id=buy_order_id, ) created_sell_order, created_stop_order = await self._create_exit_with_stop_loss_if_enabled( current_limit_order, sell_base, symbol_market, buy_order_id ) created_orders.append(created_sell_order) if created_stop_order: created_orders.append(created_stop_order) if created_orders: return created_orders if orders_should_have_been_created: raise trading_errors.OrderCreationError() raise trading_errors.MissingMinimalExchangeTradeVolume() except (trading_errors.MissingFunds, trading_errors.MissingMinimalExchangeTradeVolume, trading_errors.OrderCreationError): raise except Exception as e: self.logger.exception( e, True, f"Failed to create order : {e} ({e.__class__.__name__}). Order: " f"{current_order if current_order else None}" ) return [] async def _create_exit_with_stop_loss_if_enabled(self, sell_order_to_create, sell_base, symbol_market, buy_order_id): current_stop_order = None if self.STOP_LOSS_PRICE_MULTIPLIER and sell_order_to_create: stop_price = sell_base * self.STOP_LOSS_PRICE_MULTIPLIER oco_group = self.exchange_manager.exchange_personal_data.orders_manager.create_group( trading_personal_data.OneCancelsTheOtherOrderGroup, active_order_swap_strategy=trading_personal_data.StopFirstActiveOrderSwapStrategy() ) sell_order_to_create.add_to_order_group(oco_group) current_stop_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=trading_enums.TraderOrderType.STOP_LOSS, symbol=sell_order_to_create.symbol, current_price=trading_personal_data.adapt_price(symbol_market, stop_price), quantity=sell_order_to_create.origin_quantity, price=stop_price, side=trading_enums.TradeOrderSide.SELL, reduce_only=True, group=oco_group, associated_entry_id=buy_order_id, ) # in futures, inactive orders are not necessary if self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future: await oco_group.active_order_swap_strategy.apply_inactive_orders([sell_order_to_create, current_stop_order]) created_sell_order = await self.trading_mode.create_order(sell_order_to_create) created_stop_order = None if created_sell_order and created_sell_order.is_open() and current_stop_order: created_stop_order = await self.trading_mode.create_order(current_stop_order) self.logger.debug(f"Grouping orders: {sell_order_to_create} and {created_stop_order}") return created_sell_order, created_stop_order def _register_buy_order(self, order_id, price_weight): self.sell_targets_by_order_id[order_id] = price_weight def unregister_buy_order(self, order_id): self.sell_targets_by_order_id.pop(order_id, None) async def _get_buy_quantity_from_weight(self, ctx, volume_weight, market_quantity, currency): weighted_volume = self.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] # high risk is making larger orders, low risk is making smaller ones risk_multiplier = 1 + ((self.exchange_manager.trader.risk - decimal.Decimal("0.5")) * self.RISK_VOLUME_MULTIPLIER) weighted_volume = min(weighted_volume * risk_multiplier, trading_constants.ONE) # check configured quantity if user_amount := trading_modes.get_user_selected_order_amount(self.trading_mode, trading_enums.TradeOrderSide.BUY): return await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=user_amount, side=trading_enums.TradeOrderSide.BUY.value, reduce_only=False, is_stop_order=False, use_total_holding=False, ) * weighted_volume traded_assets_count = self.get_number_of_traded_assets() if traded_assets_count == 1: return market_quantity * self.DEFAULT_FULL_VOLUME * weighted_volume elif traded_assets_count == 2: return market_quantity * self.SOFT_MAX_CURRENCY_RATIO * weighted_volume else: currency_ratio = trading_constants.ZERO if currency != self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market: # if currency (base) is not ref market => need to check holdings ratio not to spend all ref market # into one currency (at least 3 traded assets are available here) try: currency_ratio = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio(currency) except trading_errors.MissingPriceDataError: # Can happen when ref market is not in the pair, data will be available later (ticker is now # registered) currency_ratio = self.DEFAULT_HOLDING_RATIO # linear function of % holding in this currency: volume_ratio is in [0, SOFT_MAX_CURRENCY_RATIO*0.8] volume_ratio = self.SOFT_MAX_CURRENCY_RATIO * \ (1 - min(currency_ratio * self.DELTA_RATIO, trading_constants.ONE)) return market_quantity * volume_ratio * weighted_volume def _get_sell_target_for_registered_order(self, order_id): try: return self.sell_targets_by_order_id[order_id] except KeyError: if not self.sell_targets_by_order_id: self.logger.warning(f"No registered buy orders, therefore no sell target for order with id " f"{order_id}. Using default sell target: {self.DEFAULT_SELL_TARGET}.") else: self.logger.warning(f"No sell target for order with id {order_id}. " f"Using default sell target: {self.DEFAULT_SELL_TARGET}.") return self.DEFAULT_SELL_TARGET def get_limit_price(self, price): # buy very close from current price return price * self.LIMIT_PRICE_MULTIPLIER def _generate_sell_orders(self, sell_orders_count, quantity, sell_weight, sell_base, symbol_market): volume_with_price = [] sell_max = sell_base * self.PRICE_WEIGH_TO_PRICE_PERCENT[sell_weight] adapted_sell_orders_count, increment = trading_personal_data.get_split_orders_count_and_increment( sell_base, sell_max, quantity, sell_orders_count, symbol_market, True ) if adapted_sell_orders_count: order_volume = quantity / adapted_sell_orders_count total_volume = 0 for i in range(adapted_sell_orders_count): order_price = sell_base + (increment * (i + 1)) for adapted_quantity, adapted_price \ in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( order_volume, order_price, symbol_market): total_volume += adapted_quantity volume_with_price.append((adapted_quantity, adapted_price)) if not volume_with_price: volume_with_price.append((quantity, trading_personal_data.decimal_adapt_price(symbol_market, sell_base + increment))) total_volume += quantity if total_volume < quantity: # ensure the whole target quantity is used full_quantity = volume_with_price[-1][0] + quantity - total_volume volume_with_price[-1] = (full_quantity, volume_with_price[-1][1]) return volume_with_price class DipAnalyserTradingModeProducer(trading_modes.AbstractTradingModeProducer): IGNORE_EXCHANGE_FEES = "ignore_exchange_fees" def __init__(self, channel, config, trading_mode, exchange_manager): self.ignore_exchange_fees = False super().__init__(channel, config, trading_mode, exchange_manager) self.state = trading_enums.EvaluatorStates.NEUTRAL self.first_trigger = True self.last_buy_candle = None self.base = symbol_util.parse_symbol(self.trading_mode.symbol).base def on_reload_config(self): """ Called at constructor and after the associated trading mode's reload_config. Implement if necessary """ self.ignore_exchange_fees = self.trading_mode.trading_config.get(self.IGNORE_EXCHANGE_FEES, False) async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() await super().stop() async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): # Strategies analysis for evaluated_strategy_node in matrix.get_tentacles_value_nodes( matrix_id, matrix.get_tentacle_nodes(matrix_id, exchange_name=self.exchange_name, tentacle_type=evaluators_enums.EvaluatorMatrixTypes.STRATEGIES.value, tentacle_name=Strategies.DipAnalyserStrategyEvaluator.get_name()), symbol=symbol): if evaluators_util.check_valid_eval_note(evaluators_api.get_value(evaluated_strategy_node), evaluators_api.get_type(evaluated_strategy_node), Strategies.DipAnalyserStrategyEvaluator.get_eval_type()): self.final_eval = evaluators_api.get_value(evaluated_strategy_node) await self.create_state() async def create_state(self): self.state = trading_enums.EvaluatorStates.LONG if self.first_trigger: # can't rely on previous execution buy orders: need plans for sell orders await self._cancel_buy_orders() self.first_trigger = False if self.final_eval != commons_constants.START_PENDING_EVAL_NOTE: volume_weight = self.final_eval["volume_weight"] price_weight = self.final_eval["price_weight"] await self._create_bottom_order(self.final_eval["current_candle_time"], volume_weight, price_weight) async def order_filled_callback(self, filled_order): if filled_order[trading_enums.ExchangeConstantsOrderColumns.SIDE.value] \ == trading_enums.TradeOrderSide.BUY.value: self.state = trading_enums.EvaluatorStates.SHORT paid_fees = 0 if self.ignore_exchange_fees else \ decimal.Decimal(f"{trading_personal_data.total_fees_from_order_dict(filled_order, self.base)}") sell_quantity = \ decimal.Decimal(f"{filled_order[trading_enums.ExchangeConstantsOrderColumns.FILLED.value]}") - paid_fees price = decimal.Decimal(f"{filled_order[trading_enums.ExchangeConstantsOrderColumns.PRICE.value]}") await self._create_sell_order_if_enabled( filled_order[trading_enums.ExchangeConstantsOrderColumns.ID.value], sell_quantity, price ) async def _create_sell_order_if_enabled(self, order_id, sell_quantity, buy_price): if self.exchange_manager.trader.is_enabled: data = { DipAnalyserTradingModeConsumer.ORDER_ID_KEY: order_id, DipAnalyserTradingModeConsumer.VOLUME_KEY: sell_quantity, DipAnalyserTradingModeConsumer.BUY_PRICE_KEY: buy_price, } await self.submit_trading_evaluation(cryptocurrency=self.trading_mode.cryptocurrency, symbol=self.trading_mode.symbol, time_frame=None, state=trading_enums.EvaluatorStates.SHORT, data=data) async def _create_bottom_order(self, notification_candle_time, volume_weight, price_weight): self.logger.info(f"** New buy signal for ** : {self.trading_mode.symbol}") # call orders creation method await self._create_buy_order_if_enabled(notification_candle_time, volume_weight, price_weight) async def _create_buy_order_if_enabled(self, notification_candle_time, volume_weight, price_weight): if self.exchange_manager.trader.is_enabled: # cancel previous by orders if any cancelled_orders = await self._cancel_buy_orders() if self.last_buy_candle == notification_candle_time and cancelled_orders or \ self.last_buy_candle != notification_candle_time: # if subsequent notification from the same candle: only create order if able to cancel the previous buy # to avoid multiple order on the same candle data = { DipAnalyserTradingModeConsumer.VOLUME_WEIGHT_KEY: volume_weight, DipAnalyserTradingModeConsumer.PRICE_WEIGHT_KEY: price_weight, } await self.submit_trading_evaluation(cryptocurrency=self.trading_mode.cryptocurrency, symbol=self.trading_mode.symbol, time_frame=None, state=trading_enums.EvaluatorStates.LONG, data=data) self.last_buy_candle = notification_candle_time else: self.logger.debug(f"Trader ignored buy signal for {self.trading_mode.symbol}: " f"buy order already filled.") @classmethod def get_should_cancel_loaded_orders(cls): return True def _get_current_buy_orders(self): return [order for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders( self.trading_mode.symbol) if order.side == trading_enums.TradeOrderSide.BUY] async def _cancel_buy_orders(self): cancelled_orders = False if self.exchange_manager.trader.is_enabled: for order in self._get_current_buy_orders(): try: cancelled_orders = await self.trading_mode.cancel_order(order) or cancelled_orders except (trading_errors.OrderCancelError, trading_errors.UnexpectedExchangeSideOrderStateError) as err: self.logger.warning(f"Skipping order cancel: {err}") # order can't be cancelled: don't set cancelled_orders to True return cancelled_orders ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["DipAnalyserTradingMode"], "tentacles-requirements": ["dip_analyser_strategy_evaluator"] } ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/resources/DipAnalyserTradingMode.md ================================================ DipAnalyserTradingMode is a trading mode adapted to **volatile markets**. It will look for local market bottoms, weight them and buy these bottoms. It never sells except after a buy order is filled. When a **buy order is filled, sell orders will automatically be created at a higher price** than this of the filled buy order. The number of sell orders created after each buy can be configured. A higher risk configuration will make larger buy orders when order size is not configured. To know more, checkout the full Dip analyser trading mode guide. ### Good to know - Ensure **enough funds are available in your portfolio** for OctoBot to place the **initial buy orders**. - Sell orders are never cancelled by this strategy unless stop losses are enabled, therefore it is not advised to use it on continued downtrends without using stop losses: funds might get locked in open sell orders. - Limit buy orders might be automatically cancelled and replaced when a better buy opportunity is identified. _This trading mode supports PNL history._ ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/dip_analyser_trading_mode/tests/test_dip_analyser_trading_mode.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import pytest_asyncio import os.path import asyncio import mock import decimal import async_channel.util as channel_util import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.enums as commons_enum import octobot_commons.tests.test_config as test_config import octobot_backtesting.api as backtesting_api import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchanges as exchanges import octobot_trading.personal_data as trading_personal_data import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.modes.script_keywords as script_keywords import octobot_commons.constants as commons_constants import tentacles.Evaluator.TA as TA import tentacles.Evaluator.Strategies as Strategies import tentacles.Trading.Mode as Mode import tests.test_utils.memory_check_util as memory_check_util import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def tools(): trader = None try: tentacles_manager_api.reload_tentacle_info() producer, consumer, trader = await _get_tools() yield producer, consumer, trader finally: if trader: await _stop(trader.exchange_manager) async def test_run_independent_backtestings_with_memory_check(): """ Should always be called first here to avoid other tests' related memory check issues """ tentacles_setup_config = tentacles_manager_api.create_tentacles_setup_config_with_tentacles( Mode.DipAnalyserTradingMode, Strategies.DipAnalyserStrategyEvaluator, TA.KlingerOscillatorReversalConfirmationMomentumEvaluator, TA.RSIWeightMomentumEvaluator ) config = test_config.load_test_config() config[commons_constants.CONFIG_TIME_FRAME] = [commons_enum.TimeFrames.FOUR_HOURS] await memory_check_util.run_independent_backtestings_with_memory_check(config, tentacles_setup_config) async def test_init(tools): producer, consumer, trader = tools # trading mode assert producer.trading_mode is consumer.trading_mode assert producer.trading_mode.sell_orders_per_buy == 3 # producer assert producer.last_buy_candle is None assert producer.first_trigger # consumer assert consumer.sell_targets_by_order_id == {} assert consumer.PRICE_WEIGH_TO_PRICE_PERCENT == { 1: decimal.Decimal("1.04"), 2: decimal.Decimal("1.07"), 3: decimal.Decimal("1.1"), } assert consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT == { 1: decimal.Decimal("0.5"), 2: decimal.Decimal("0.7"), 3: decimal.Decimal("1"), } async def test_create_limit_bottom_order(tools): producer, consumer, trader = tools price = decimal.Decimal("1000") market_quantity = decimal.Decimal("2") volume_weight = decimal.Decimal("1") risk_multiplier = decimal.Decimal("1.1") market_status = producer.exchange_manager.exchange.get_market_status(producer.trading_mode.symbol, with_fixer=False) _origin_decimal_adapt_order_quantity_because_fees = trading_personal_data.decimal_adapt_order_quantity_because_fees def _decimal_adapt_order_quantity_because_fees( exchange_manager, symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, side: trading_enums.TradeOrderSide, ): return quantity with mock.patch.object( trading_personal_data, "decimal_adapt_order_quantity_because_fees", mock.Mock(side_effect=_decimal_adapt_order_quantity_because_fees) ) as decimal_adapt_order_quantity_because_fees_mock: await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) await asyncio_tools.wait_asyncio_next_cycle() order = trading_api.get_open_orders(trader.exchange_manager)[0] adapted_args = list(decimal_adapt_order_quantity_because_fees_mock.mock_calls[0].args) adapted_args[3] = trading_personal_data.decimal_adapt_quantity(market_status, adapted_args[3]) adapted_args[4] = trading_personal_data.decimal_adapt_price(market_status, adapted_args[4]) assert adapted_args == [ producer.exchange_manager, producer.trading_mode.symbol, trading_enums.TraderOrderType.BUY_LIMIT, order.origin_quantity, order.origin_price, trading_enums.TradeOrderSide.BUY, ] assert isinstance(order, trading_personal_data.BuyLimitOrder) expected_quantity = market_quantity * risk_multiplier * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * \ consumer.SOFT_MAX_CURRENCY_RATIO assert order.origin_quantity == expected_quantity expected_price = price * consumer.LIMIT_PRICE_MULTIPLIER assert order.origin_price == expected_price portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio assert portfolio.get_currency_portfolio("USDT").available > trading_constants.ZERO assert order.order_id in consumer.sell_targets_by_order_id async def test_create_market_bottom_order(tools): producer, consumer, trader = tools price = decimal.Decimal("1000") market_quantity = decimal.Decimal("2") volume_weight = decimal.Decimal("1") risk_multiplier = decimal.Decimal("1.1") consumer.USE_BUY_MARKET_ORDERS_VALUE = True trades = trading_api.get_trade_history(trader.exchange_manager) assert trades == [] market_status = producer.exchange_manager.exchange.get_market_status(producer.trading_mode.symbol, with_fixer=False) _origin_decimal_adapt_order_quantity_because_fees = trading_personal_data.decimal_adapt_order_quantity_because_fees def _decimal_adapt_order_quantity_because_fees( exchange_manager, symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, side: trading_enums.TradeOrderSide, ): return quantity with mock.patch.object( trading_personal_data, "decimal_adapt_order_quantity_because_fees", mock.Mock(side_effect=_decimal_adapt_order_quantity_because_fees) ) as decimal_adapt_order_quantity_because_fees_mock: await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed (market order is instantly filled) await asyncio.create_task(_check_open_orders_count(trader, 0)) await asyncio_tools.wait_asyncio_next_cycle() trade = trading_api.get_trade_history(trader.exchange_manager)[0] adapted_args = list(decimal_adapt_order_quantity_because_fees_mock.mock_calls[0].args) adapted_args[3] = trading_personal_data.decimal_adapt_quantity(market_status, adapted_args[3]) adapted_args[4] = trading_personal_data.decimal_adapt_price(market_status, adapted_args[4]) assert adapted_args == [ producer.exchange_manager, producer.trading_mode.symbol, trading_enums.TraderOrderType.BUY_MARKET, trade.origin_quantity, trade.origin_price, trading_enums.TradeOrderSide.BUY, ] assert trade.trade_type == trading_enums.TraderOrderType.BUY_MARKET expected_quantity = market_quantity * risk_multiplier * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * \ consumer.SOFT_MAX_CURRENCY_RATIO assert trade.origin_quantity == expected_quantity # no price multiplier used as it is a market order (use market price) assert trade.origin_price == price portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio assert portfolio.get_currency_portfolio("USDT").available > trading_constants.ZERO assert trade.origin_order_id in consumer.sell_targets_by_order_id async def test_create_bottom_order_with_configured_quantity(tools): producer, consumer, trader = tools producer.trading_mode.trading_config[trading_constants.CONFIG_BUY_ORDER_AMOUNT] = \ f"20{script_keywords.QuantityType.PERCENT.value}" price = decimal.Decimal("1000") market_quantity = decimal.Decimal("2") volume_weight = decimal.Decimal("1") risk_multiplier = decimal.Decimal("1.1") # force portfolio value trader.exchange_manager.exchange_personal_data. \ portfolio_manager.portfolio_value_holder.portfolio_current_value = decimal.Decimal(1) await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) await asyncio_tools.wait_asyncio_next_cycle() order = trading_api.get_open_orders(trader.exchange_manager)[0] default_expected_quantity = market_quantity * risk_multiplier * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * \ consumer.SOFT_MAX_CURRENCY_RATIO expected_quantity = market_quantity * risk_multiplier * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * \ decimal.Decimal("0.2") assert default_expected_quantity != expected_quantity assert order.origin_quantity == expected_quantity expected_price = price * consumer.LIMIT_PRICE_MULTIPLIER assert order.origin_price == expected_price portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio assert trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available > trading_constants.ZERO assert order.order_id in consumer.sell_targets_by_order_id async def test_create_too_large_bottom_order(tools): producer, consumer, trader = tools trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available = decimal.Decimal("200000000000000") trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").total = decimal.Decimal("200000000000000") await producer._create_bottom_order(1, 1, 1) # create as task to allow creator's queue to get processed for _ in range(37): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, 37)) assert trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available > trading_constants.ZERO async def test_create_too_small_bottom_order(tools): producer, consumer, trader = tools trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available = decimal.Decimal("0.01") trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").total = decimal.Decimal("0.01") await producer._create_bottom_order(1, 1, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) assert trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available == decimal.Decimal("0.01") async def test_create_bottom_order_replace_current(tools): producer, consumer, trader = tools price = decimal.Decimal("1000") market_quantity = decimal.Decimal("2") volume_weight = decimal.Decimal("1") risk_multiplier = decimal.Decimal("1.1") portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio # first order await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) await asyncio_tools.wait_asyncio_next_cycle() first_order = trading_api.get_open_orders(trader.exchange_manager)[0] assert first_order.status == trading_enums.OrderStatus.OPEN expected_quantity = market_quantity * risk_multiplier * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * consumer.SOFT_MAX_CURRENCY_RATIO assert first_order.origin_quantity == expected_quantity expected_price = price * consumer.LIMIT_PRICE_MULTIPLIER assert first_order.origin_price == expected_price available_after_order = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available assert available_after_order > trading_constants.ZERO assert first_order.order_id in consumer.sell_targets_by_order_id # second order, same weight await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) await asyncio_tools.wait_asyncio_next_cycle() second_order = trading_api.get_open_orders(trader.exchange_manager)[0] assert first_order.status == trading_enums.OrderStatus.CANCELED assert second_order.status == trading_enums.OrderStatus.OPEN assert second_order is not first_order assert second_order.origin_quantity == first_order.origin_quantity assert second_order.origin_price == first_order.origin_price assert trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available == available_after_order # order still in sell_targets_by_order_id: cancelling orders doesn't remove them for this assert first_order.order_id in consumer.sell_targets_by_order_id assert second_order.order_id in consumer.sell_targets_by_order_id # third order, different weight volume_weight = 3 await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) await asyncio_tools.wait_asyncio_next_cycle() third_order = trading_api.get_open_orders(trader.exchange_manager)[0] assert second_order.status == trading_enums.OrderStatus.CANCELED assert third_order.status == trading_enums.OrderStatus.OPEN assert third_order is not second_order and third_order is not first_order expected_quantity = market_quantity * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * consumer.SOFT_MAX_CURRENCY_RATIO assert third_order.origin_quantity != first_order.origin_quantity assert third_order.origin_quantity == expected_quantity assert third_order.origin_price == first_order.origin_price available_after_third_order = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available assert available_after_third_order < available_after_order assert second_order.order_id in consumer.sell_targets_by_order_id assert third_order.order_id in consumer.sell_targets_by_order_id # fill third order await _fill_order(third_order, trader, trigger_update_callback=False, consumer=consumer) # fourth order: can't be placed: an order on this candle got filled volume_weight = 3 await producer._create_bottom_order(1, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) # fifth order: in the next candle volume_weight = 2 new_market_quantity = decimal.Decimal(f'{trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available}') \ / price await producer._create_bottom_order(2, volume_weight, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) await asyncio_tools.wait_asyncio_next_cycle() fifth_order = trading_api.get_open_orders(trader.exchange_manager)[0] assert third_order.status == trading_enums.OrderStatus.FILLED assert fifth_order.status == trading_enums.OrderStatus.OPEN assert fifth_order is not third_order and fifth_order is not second_order and fifth_order is not first_order expected_quantity = new_market_quantity * risk_multiplier * \ consumer.VOLUME_WEIGH_TO_VOLUME_PERCENT[volume_weight] * consumer.SOFT_MAX_CURRENCY_RATIO assert fifth_order.origin_quantity != first_order.origin_quantity assert fifth_order.origin_quantity != third_order.origin_quantity assert fifth_order.origin_quantity == trading_personal_data.decimal_trunc_with_n_decimal_digits(expected_quantity, 8) assert fifth_order.origin_price == first_order.origin_price assert trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USDT").available < available_after_third_order assert first_order.order_id in consumer.sell_targets_by_order_id assert second_order.order_id in consumer.sell_targets_by_order_id # third_order still in _get_order_identifier to keep history assert third_order.order_id in consumer.sell_targets_by_order_id assert fifth_order.order_id in consumer.sell_targets_by_order_id async def test_create_sell_orders_without_stop_loss(tools): producer, consumer, trader = tools sell_quantity = decimal.Decimal("5") sell_target = 2 buy_price = decimal.Decimal("100") order_id = "a" consumer.STOP_LOSS_PRICE_MULTIPLIER = trading_constants.ZERO consumer.sell_targets_by_order_id[order_id] = sell_target await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) assert all(o.associated_entry_ids == ["a"] for o in open_orders) assert all(isinstance(o, trading_personal_data.SellLimitOrder) for o in open_orders) assert not any(isinstance(o, trading_personal_data.StopLossOrder) for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) # rounding because orders to create volumes are X.33333 assert sell_quantity * decimal.Decimal("0.9999") <= total_sell_quantity <= sell_quantity max_price = buy_price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[sell_target] increment = (max_price - buy_price) / consumer.trading_mode.sell_orders_per_buy assert open_orders[0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + increment, 8) assert open_orders[1].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + 2 * increment, 8) assert open_orders[2].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + 3 * increment, 8) # now fill a sell order await _fill_order(open_orders[0], trader, trigger_update_callback=False, consumer=consumer) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy - 1)) sell_quantity = decimal.Decimal("3") sell_target = 3 buy_price = decimal.Decimal("2525") order_id_2 = "b" consumer.sell_targets_by_order_id[order_id_2] = sell_target await producer._create_sell_order_if_enabled(order_id_2, sell_quantity, buy_price) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy * 2 - 1)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) assert all(o.associated_entry_ids == ["a"] for o in open_orders[:2]) assert all(o.associated_entry_ids == ["b"] for o in open_orders[2:]) assert all(isinstance(o, trading_personal_data.SellLimitOrder) for o in open_orders) assert not any(isinstance(o, trading_personal_data.StopLossOrder) for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders if o.origin_price > 150) assert total_sell_quantity == sell_quantity max_price = buy_price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[sell_target] increment = (max_price - buy_price) / consumer.trading_mode.sell_orders_per_buy assert open_orders[2 + 0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + increment, 8) assert open_orders[2 + 1].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + 2 * increment, 8) assert open_orders[2 + 2].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + 3 * increment, 8) # now fill a sell order await _fill_order(open_orders[-1], trader, trigger_update_callback=False, consumer=consumer) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy * 2 - 2)) async def test_create_sell_orders_with_stop_loss(tools): producer, consumer, trader = tools trader.enable_inactive_orders = True sell_quantity = decimal.Decimal("5") sell_target = 2 buy_price = decimal.Decimal("100") order_id = "a" consumer.STOP_LOSS_PRICE_MULTIPLIER = decimal.Decimal("0.75") stop_price = consumer.STOP_LOSS_PRICE_MULTIPLIER * buy_price consumer.sell_targets_by_order_id[order_id] = sell_target await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() # * 2 to account for the stop order associated to each sell order await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy * 2)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.associated_entry_ids == ["a"] for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) assert any(isinstance(o, (trading_personal_data.SellLimitOrder, trading_personal_data.StopLossOrder)) for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) # rounding because orders to create volumes are X.33333 assert sell_quantity * decimal.Decimal("0.9999") * 2 <= total_sell_quantity <= sell_quantity * 2 # ensure order quantity and groups limit_orders = [o for o in open_orders if isinstance(o, trading_personal_data.SellLimitOrder)] stop_orders = [o for o in open_orders if isinstance(o, trading_personal_data.StopLossOrder)] assert len(limit_orders) == len(stop_orders) for limit, stop in zip(limit_orders, stop_orders): assert isinstance(limit.order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) assert limit.order_group is stop.order_group assert limit.origin_quantity == stop.origin_quantity assert limit.origin_price > stop.origin_price assert stop.origin_price == stop_price assert stop.is_active is True assert limit.is_active is False group_orders = trader.exchange_manager.exchange_personal_data.orders_manager.get_order_from_group( limit.order_group.name ) assert group_orders == [limit, stop] # now fill a sell order await _fill_order(limit_orders[0], trader, trigger_update_callback=False, consumer=consumer, closed_orders_count=2) # create as task to allow creator's queue to get processed # also check that associated stop loss is cancelled await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy * 2 - 2)) sell_quantity = decimal.Decimal("3") sell_target = 3 buy_price = decimal.Decimal("2525") order_id_2 = "b" consumer.sell_targets_by_order_id[order_id_2] = sell_target await producer._create_sell_order_if_enabled(order_id_2, sell_quantity, buy_price) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy * 2 * 2 - 2)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders if o.origin_price > 150) assert total_sell_quantity == sell_quantity * 2 max_price = buy_price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[sell_target] increment = (max_price - buy_price) / consumer.trading_mode.sell_orders_per_buy limit_orders = [o for o in open_orders if isinstance(o, trading_personal_data.SellLimitOrder)] stop_orders = [o for o in open_orders if isinstance(o, trading_personal_data.StopLossOrder)] assert limit_orders[2 + 0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + increment, 8) assert limit_orders[2 + 1].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + 2 * increment, 8) assert limit_orders[2 + 2].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + 3 * increment, 8) # now fill a stop order await _fill_order(stop_orders[-1], trader, trigger_update_callback=False, consumer=consumer, closed_orders_count=2) # create as task to allow creator's queue to get processed # associated sell order gets cancelled await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy * 2 * 2 - 2 * 2)) async def test_create_too_large_sell_orders(tools): producer, consumer, trader = tools # case 1: too many orders to create: problem sell_quantity = decimal.Decimal("500000000") sell_target = 2 buy_price = decimal.Decimal("10000000") portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = sell_quantity trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = sell_quantity order_id = "a" consumer.sell_targets_by_order_id[order_id] = sell_target await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) # case 2: create split sell orders sell_quantity = decimal.Decimal("5000000") buy_price = decimal.Decimal("3000000") await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed for _ in range(17): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, 17)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) # rounding because orders to create volumes are with truncated decimals assert sell_quantity * decimal.Decimal("0.9999") <= total_sell_quantity <= sell_quantity max_price = buy_price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[sell_target] increment = (max_price - buy_price) / 17 assert open_orders[0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(buy_price + increment, 8) assert open_orders[-1].origin_price == max_price async def test_create_too_small_sell_orders(tools): producer, consumer, trader = tools # case 1: not enough to create any order: problem sell_quantity = decimal.Decimal("0.001") sell_target = 2 buy_price = decimal.Decimal("0.001") order_id = "a" consumer.sell_targets_by_order_id[order_id] = sell_target await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 0)) # case 2: create less than 3 orders: 1 order sell_quantity = decimal.Decimal("0.1") buy_price = decimal.Decimal("0.01") await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert len(open_orders) == 1 assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) assert total_sell_quantity == sell_quantity max_price = buy_price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[sell_target] assert open_orders[0].origin_price == max_price # case 3: create less than 3 orders: 2 orders sell_quantity = decimal.Decimal("0.2") sell_target = 2 buy_price = decimal.Decimal("0.01") # keep same order id to test no issue with it await producer._create_sell_order_if_enabled(order_id, sell_quantity, buy_price) # create as task to allow creator's queue to get processed for _ in range(3): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, 3)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) second_total_sell_quantity = sum(o.origin_quantity for o in open_orders if o.origin_price >= 0.0107) assert decimal.Decimal(f"{second_total_sell_quantity}") == sell_quantity max_price = buy_price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[sell_target] increment = (max_price - buy_price) / 2 assert open_orders[1].origin_price == buy_price + increment assert open_orders[2].origin_price == max_price async def test_order_fill_callback_with_limit_entry(tools): producer, consumer, trader = tools volume_weight = 1 price_weight = 1 await producer._create_bottom_order(1, volume_weight, price_weight) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) # change weights to ensure no interference volume_weight = 3 price_weight = 3 open_orders = trading_api.get_open_orders(trader.exchange_manager) to_fill_order = open_orders[0] await _fill_order(to_fill_order, trader, consumer=consumer) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy)) assert to_fill_order.status == trading_enums.OrderStatus.FILLED open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) assert to_fill_order.origin_quantity * decimal.Decimal("0.95") <= total_sell_quantity <= to_fill_order.origin_quantity price = decimal.Decimal(f"{to_fill_order.filled_price}") max_price = price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[1] increment = (max_price - price) / consumer.trading_mode.sell_orders_per_buy assert open_orders[0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + increment, 8) assert open_orders[1].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + 2 * increment, 8) assert open_orders[2].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + 3 * increment, 8) # now fill a sell order await _fill_order(open_orders[0], trader, consumer=consumer) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy - 1)) # new buy order await producer._create_bottom_order(2, volume_weight, price_weight) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy)) async def test_order_fill_callback_with_market_entry(tools): producer, consumer, trader = tools volume_weight = 1 price_weight = 1 consumer.USE_BUY_MARKET_ORDERS_VALUE = True await producer._create_bottom_order(1, volume_weight, price_weight) # create as task to allow creator's queue to get processed # market order is instantly filled await asyncio.create_task(_check_open_orders_count(trader, 0)) entry = trading_api.get_trade_history(trader.exchange_manager)[0] # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy)) assert entry.status == trading_enums.OrderStatus.FILLED open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) assert entry.origin_quantity * decimal.Decimal("0.95") <= total_sell_quantity <= entry.origin_quantity price = decimal.Decimal(f"{entry.executed_price}") max_price = price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[1] increment = (max_price - price) / consumer.trading_mode.sell_orders_per_buy assert open_orders[0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + increment, 8) assert open_orders[1].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + 2 * increment, 8) assert open_orders[2].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + 3 * increment, 8) async def test_order_fill_callback_without_fees(tools): producer, consumer, trader = tools producer.ignore_exchange_fees = True volume_weight = 1 price_weight = 1 await producer._create_bottom_order(1, volume_weight, price_weight) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) open_orders = trading_api.get_open_orders(trader.exchange_manager) to_fill_order = open_orders[0] await _fill_order(to_fill_order, trader, consumer=consumer) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy)) assert to_fill_order.status == trading_enums.OrderStatus.FILLED open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) assert total_sell_quantity == to_fill_order.origin_quantity async def test_order_fill_callback_without_fees_adapted_rounding(tools): producer, consumer, trader = tools producer.ignore_exchange_fees = True volume_weight = 1 price_weight = 1 await producer._create_bottom_order(1, volume_weight, price_weight) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) open_orders = trading_api.get_open_orders(trader.exchange_manager) to_fill_order = open_orders[0] to_fill_order.origin_quantity = decimal.Decimal("0.000167") to_fill_order.origin_price = decimal.Decimal("200") await _fill_order(to_fill_order, trader, consumer=consumer) # create as task to allow creator's queue to get processed for _ in range(consumer.trading_mode.sell_orders_per_buy): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, consumer.trading_mode.sell_orders_per_buy)) assert to_fill_order.status == trading_enums.OrderStatus.FILLED open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) assert total_sell_quantity == to_fill_order.origin_quantity async def test_order_fill_callback_not_in_db(tools): producer, consumer, trader = tools await producer._create_bottom_order(2, 1, 1) # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(trader, 1)) open_orders = trading_api.get_open_orders(trader.exchange_manager) to_fill_order = open_orders[0] await _fill_order(to_fill_order, trader, trigger_update_callback=False, consumer=consumer) # remove order from db consumer.sell_targets_by_order_id = {} await consumer.trading_mode._order_notification_callback(None, trader.exchange_manager.id, None, symbol=to_fill_order.symbol, order=to_fill_order.to_dict(), update_type=trading_enums.OrderUpdateType.STATE_CHANGE.value, is_from_bot=True) # create as task to allow creator's queue to get processed for _ in range(3): await asyncio_tools.wait_asyncio_next_cycle() await asyncio.create_task(_check_open_orders_count(trader, 3)) open_orders = trading_api.get_open_orders(trader.exchange_manager) assert all(o.status == trading_enums.OrderStatus.OPEN for o in open_orders) assert all(o.side == trading_enums.TradeOrderSide.SELL for o in open_orders) total_sell_quantity = sum(o.origin_quantity for o in open_orders) assert to_fill_order.origin_quantity * decimal.Decimal("0.95") <= total_sell_quantity <= to_fill_order.origin_quantity price = decimal.Decimal(to_fill_order.filled_price) max_price = price * consumer.PRICE_WEIGH_TO_PRICE_PERCENT[consumer.DEFAULT_SELL_TARGET] increment = (max_price - price) / consumer.trading_mode.sell_orders_per_buy assert open_orders[0].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + increment, 8) assert open_orders[1].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + 2 * increment, 8) assert open_orders[2].origin_price == \ trading_personal_data.decimal_trunc_with_n_decimal_digits(price + 3 * increment, 8) async def _check_open_orders_count(trader, count): assert len(trading_api.get_open_orders(trader.exchange_manager)) == count async def _get_tools(symbol="BTC/USDT"): config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator( exchange_manager.config, exchange_manager, backtesting ) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.DipAnalyserTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) return mode.producers[0], mode.get_trading_mode_consumers()[0], trader async def _fill_order(order, trader, trigger_update_callback=True, ignore_open_orders=False, consumer=None, closed_orders_count=1): initial_len = len(trading_api.get_open_orders(trader.exchange_manager)) await order.on_fill(force_fill=True) if order.status == trading_enums.OrderStatus.FILLED: if not ignore_open_orders: assert len(trading_api.get_open_orders(trader.exchange_manager)) == initial_len - closed_orders_count if trigger_update_callback: await asyncio_tools.wait_asyncio_next_cycle() else: with mock.patch.object(consumer, "create_new_orders", new=mock.AsyncMock()): await asyncio_tools.wait_asyncio_next_cycle() async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() ================================================ FILE: Trading/Mode/grid_trading_mode/__init__.py ================================================ from .grid_trading import GridTradingMode ================================================ FILE: Trading/Mode/grid_trading_mode/config/GridTradingMode.json ================================================ { "required_strategies": [], "pair_settings": [ { "pair": "BTC/USDT", "flat_spread": 2000, "flat_increment": 1000, "buy_orders_count": 25, "sell_orders_count": 25, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "reinvest_profits": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": false, "enable_trailing_down": false, "funds_redispatch_interval": 24 }, { "pair": "ADA/ETH", "flat_spread": 0.00002, "flat_increment": 0.00001, "buy_orders_count": 25, "sell_orders_count": 25, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "reinvest_profits": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": false, "enable_trailing_down": false, "funds_redispatch_interval": 24 }, { "pair": "ETH/USDT", "flat_spread": 10, "flat_increment": 5, "buy_orders_count": 25, "sell_orders_count": 25, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "reinvest_profits": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": false, "enable_trailing_down": false, "funds_redispatch_interval": 24 } ] } ================================================ FILE: Trading/Mode/grid_trading_mode/grid_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import dataclasses import decimal import typing import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_trading.api as trading_api import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.errors as trading_errors import tentacles.Trading.Mode.staggered_orders_trading_mode.staggered_orders_trading as staggered_orders_trading @dataclasses.dataclass class AllowedPriceRange: lower_bound: decimal.Decimal = trading_constants.ZERO higher_bound: decimal.Decimal = trading_constants.ZERO class GridTradingMode(staggered_orders_trading.StaggeredOrdersTradingMode): CONFIG_FLAT_SPREAD = "flat_spread" CONFIG_FLAT_INCREMENT = "flat_increment" CONFIG_BUY_ORDERS_COUNT = "buy_orders_count" CONFIG_SELL_ORDERS_COUNT = "sell_orders_count" LIMIT_ORDERS_IF_NECESSARY = "limit_orders_if_necessary" USER_COMMAND_CREATE_ORDERS = "create initial orders" USER_COMMAND_STOP_ORDERS_CREATION = "stop initial orders creation" USER_COMMAND_PAUSE_ORDER_MIRRORING = "pause orders mirroring" USER_COMMAND_TRADING_PAIR = "trading pair" USER_COMMAND_PAUSE_TIME = "pause length in seconds" SUPPORTS_HEALTH_CHECK = False # WIP # set True when self.health_check is implemented def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ default_config = self.get_default_pair_config( "BTC/USDT", 0.05, 0.005, None, None, None, None, None ) self.UI.user_input(self.CONFIG_PAIR_SETTINGS, commons_enums.UserInputTypes.OBJECT_ARRAY, self.trading_config.get(self.CONFIG_PAIR_SETTINGS, None), inputs, item_title="Pair configuration", other_schema_values={"minItems": 1, "uniqueItems": True}, title="Configuration for each traded pairs.") self.UI.user_input(self.CONFIG_PAIR, commons_enums.UserInputTypes.TEXT, default_config[self.CONFIG_PAIR], inputs, other_schema_values={"minLength": 3, "pattern": commons_constants.TRADING_SYMBOL_REGEX}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Name of the traded pair.") self.UI.user_input( self.CONFIG_FLAT_SPREAD, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_FLAT_SPREAD], inputs, min_val=0, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Spread: price difference between the closest buy and sell orders. Denominated in the quote currency " "(600 for a 600 USDT spread on BTC/USDT).", ) self.UI.user_input( self.CONFIG_FLAT_INCREMENT, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_FLAT_INCREMENT], inputs, min_val=0, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Increment: price difference between two orders of the same side. Denominated in the quote currency " "(200 for a 200 USDT spread on BTC/USDT). " "WARNING: this should be lower than the Spread value: profitability is close to Spread-Increment.", ) self.UI.user_input( self.CONFIG_BUY_ORDERS_COUNT, commons_enums.UserInputTypes.INT, default_config[self.CONFIG_BUY_ORDERS_COUNT], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Buy orders count: number of initial buy orders to create. Make sure to have enough funds " "to create that many orders.", ) self.UI.user_input( self.CONFIG_SELL_ORDERS_COUNT, commons_enums.UserInputTypes.INT, default_config[self.CONFIG_SELL_ORDERS_COUNT], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Sell orders count: Number of initial sell orders to create. Make sure to have enough funds " "to create that many orders.", ) self.UI.user_input( self.CONFIG_BUY_FUNDS, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_BUY_FUNDS], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Total buy funds: total funds to use for buy orders creation. " "Denominated in quote currency: enter 1000 to create a grid on BTC/USDT using up to a total of 1000 " "USDT in its buy orders. Set 0 to use all available funds in portfolio. " "A value is required to use the same currency simultaneously in multiple traded pairs.", ) self.UI.user_input( self.CONFIG_SELL_FUNDS, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_SELL_FUNDS], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Total sell funds: total funds to use for sell orders creation. " "Denominated in base currency: enter 0.01 to create a grid on BTC/USDT using up to a total of 0.01 " "BTC in its sell orders. Set 0 to use all available funds in portfolio. " "A value is required to use the same currency simultaneously in multiple traded pairs.", ) self.UI.user_input( self.CONFIG_STARTING_PRICE, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_STARTING_PRICE], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Starting price: price to compute initial orders from. Set 0 to use current " "exchange price during initial grid orders creation.", ) self.UI.user_input( self.CONFIG_BUY_VOLUME_PER_ORDER, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_BUY_VOLUME_PER_ORDER], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Buy orders volume: volume of each buy order in base currency. Set 0 to use all " "available funds in portfolio (or total buy funds if set) and create orders with constant " "total order cost (price * volume).", ) self.UI.user_input( self.CONFIG_SELL_VOLUME_PER_ORDER, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_SELL_VOLUME_PER_ORDER], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Sell orders volume: volume of each sell order in base currency. Set 0 to use all " "available funds in portfolio (or total sell funds if set) and create orders with constant " "total order cost (price * volume).", ) self.UI.user_input( self.CONFIG_IGNORE_EXCHANGE_FEES, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_IGNORE_EXCHANGE_FEES], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Ignore exchange fees: when checked, exchange fees won't be considered when creating mirror orders. " "When unchecked, a part of the total volume will be reduced to take exchange " "fees into account.", ) self.UI.user_input( self.CONFIG_MIRROR_ORDER_DELAY, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_MIRROR_ORDER_DELAY], inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Mirror order delay: Seconds to wait for before creating a mirror order when an order " "is filled. This can generate extra profits on quick market moves.", ) self.UI.user_input( self.CONFIG_USE_EXISTING_ORDERS_ONLY, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_USE_EXISTING_ORDERS_ONLY], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Use existing orders only: when checked, new orders will only be created upon pre-existing orders " "fill. OctoBot won't create orders at startup: it will use the ones already on exchange instead. " "This mode allows grid orders to operate on user created orders. Can't work on trading simulator.", ) self.UI.user_input( self.CONFIG_ENABLE_TRAILING_UP, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_ENABLE_TRAILING_UP], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Trailing up: when checked, the whole grid will be cancelled and recreated when price goes above the " "highest selling price. This might require the grid to perform a buy market order to be " "able to recreate the grid new sell orders at the updated price.", ) self.UI.user_input( self.CONFIG_ENABLE_TRAILING_DOWN, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_ENABLE_TRAILING_DOWN], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Trailing down: when checked, the whole grid will be cancelled and recreated when price goes bellow" " the lowest buying price. This might require the grid to perform a sell market order to be " "able to recreate the grid new buy orders at the updated price. " "Warning: when trailing down, the sell order required to recreate the buying side of the grid " "might generate a loss.", ) self.UI.user_input( self.CONFIG_ENABLE_TRAILING_UP, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_ENABLE_TRAILING_UP], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Trailing up: when checked, the whole grid will be cancelled and recreated when price goes above the " "highest selling price. This might require the grid to perform a buy market order to be " "able to recreate the grid new sell orders at the updated price.", ) self.UI.user_input( self.CONFIG_ORDER_BY_ORDER_TRAILING, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_ORDER_BY_ORDER_TRAILING], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Order by order trailing: when checked, the grid will trail order by order instead of the whole grid at once, which is adapted to less volatile markets.", ) self.UI.user_input( self.CONFIG_ALLOW_FUNDS_REDISPATCH, commons_enums.UserInputTypes.BOOLEAN, default_config[self.CONFIG_ALLOW_FUNDS_REDISPATCH], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Auto-dispatch new funds: when checked, new available funds will be dispatched into existing " "orders when additional funds become available. Funds redispatch check happens once a day " "around your OctoBot start time.", ) self.UI.user_input( self.CONFIG_FUNDS_REDISPATCH_INTERVAL, commons_enums.UserInputTypes.FLOAT, default_config[self.CONFIG_FUNDS_REDISPATCH_INTERVAL], inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Auto-dispatch interval: hours between each funds redispatch check.", editor_options={ commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: { self.CONFIG_ALLOW_FUNDS_REDISPATCH: True } } ) @classmethod def get_default_pair_config( cls, symbol, flat_spread: float, flat_increment: float, buy_count: typing.Optional[int], sell_count: typing.Optional[int], enable_trailing_up: typing.Optional[bool], enable_trailing_down: typing.Optional[bool], order_by_order_trailing: typing.Optional[bool] ) -> dict: return { cls.CONFIG_PAIR: symbol, cls.CONFIG_FLAT_SPREAD: flat_spread, cls.CONFIG_FLAT_INCREMENT: flat_increment, cls.CONFIG_BUY_ORDERS_COUNT: buy_count or 20, cls.CONFIG_SELL_ORDERS_COUNT: sell_count or 20, cls.CONFIG_SELL_FUNDS: 0, cls.CONFIG_BUY_FUNDS: 0, cls.CONFIG_STARTING_PRICE: 0, cls.CONFIG_BUY_VOLUME_PER_ORDER: 0, cls.CONFIG_SELL_VOLUME_PER_ORDER: 0, cls.CONFIG_IGNORE_EXCHANGE_FEES: True, cls.CONFIG_MIRROR_ORDER_DELAY: 0, cls.CONFIG_USE_EXISTING_ORDERS_ONLY: False, cls.CONFIG_ALLOW_FUNDS_REDISPATCH: False, cls.CONFIG_ENABLE_TRAILING_UP: enable_trailing_up or False, cls.CONFIG_ENABLE_TRAILING_DOWN: enable_trailing_down or False, # enabled by default cls.CONFIG_ORDER_BY_ORDER_TRAILING: True if order_by_order_trailing is None else order_by_order_trailing, cls.CONFIG_FUNDS_REDISPATCH_INTERVAL: 24, } def get_mode_producer_classes(self) -> list: return [GridTradingModeProducer] def get_mode_consumer_classes(self) -> list: return [GridTradingModeConsumer] async def user_commands_callback(self, bot_id, subject, action, data) -> None: await super().user_commands_callback(bot_id, subject, action, data) if data and data.get(GridTradingMode.USER_COMMAND_TRADING_PAIR, "").upper() == self.symbol: self.logger.info(f"Received {action} command for {self.symbol}.") if action == GridTradingMode.USER_COMMAND_CREATE_ORDERS: await self.producers[0].trigger_staggered_orders_creation() elif action == GridTradingMode.USER_COMMAND_STOP_ORDERS_CREATION: await self.get_trading_mode_consumers()[0].cancel_orders_creation() elif action == GridTradingMode.USER_COMMAND_PAUSE_ORDER_MIRRORING: delay = float(data.get(GridTradingMode.USER_COMMAND_PAUSE_TIME, 0)) self.producers[0].start_mirroring_pause(delay) @classmethod def get_user_commands(cls) -> dict: """ Return the dict of user commands for this tentacle :return: the commands dict """ return { **super().get_user_commands(), **{ GridTradingMode.USER_COMMAND_CREATE_ORDERS: { GridTradingMode.USER_COMMAND_TRADING_PAIR: "text" }, GridTradingMode.USER_COMMAND_STOP_ORDERS_CREATION: { GridTradingMode.USER_COMMAND_TRADING_PAIR: "text" }, GridTradingMode.USER_COMMAND_PAUSE_ORDER_MIRRORING: { GridTradingMode.USER_COMMAND_TRADING_PAIR: "text", GridTradingMode.USER_COMMAND_PAUSE_TIME: "number" } } } class GridTradingModeConsumer(staggered_orders_trading.StaggeredOrdersTradingModeConsumer): pass class GridTradingModeProducer(staggered_orders_trading.StaggeredOrdersTradingModeProducer): # Disable health check HEALTH_CHECK_INTERVAL_SECS = None ORDERS_DESC = "grid" RECENT_TRADES_ALLOWED_TIME = 2 * commons_constants.DAYS_TO_SECONDS def __init__(self, channel, config, trading_mode, exchange_manager): self.buy_orders_count = self.sell_orders_count = None self.sell_price_range = AllowedPriceRange() self.buy_price_range = AllowedPriceRange() super().__init__(channel, config, trading_mode, exchange_manager) self._expect_missing_orders = True self._skip_order_restore_on_recently_closed_orders = False self._use_recent_trades_for_order_restore = True self.allow_virtual_orders = False def read_config(self): self.mode = staggered_orders_trading.StrategyModes.FLAT # init decimals from str to remove native float rounding self.flat_spread = None if self.symbol_trading_config[self.trading_mode.CONFIG_FLAT_SPREAD] is None \ else decimal.Decimal(str(self.symbol_trading_config[self.trading_mode.CONFIG_FLAT_SPREAD])) self.flat_increment = None if self.symbol_trading_config[self.trading_mode.CONFIG_FLAT_INCREMENT] is None \ else decimal.Decimal(str(self.symbol_trading_config[self.trading_mode.CONFIG_FLAT_INCREMENT])) # decimal.Decimal operations are supporting int values, no need to convert these into decimal.Decimal self.buy_orders_count = self.symbol_trading_config[self.trading_mode.CONFIG_BUY_ORDERS_COUNT] self.sell_orders_count = self.symbol_trading_config[self.trading_mode.CONFIG_SELL_ORDERS_COUNT] self.operational_depth = self.buy_orders_count + self.sell_orders_count self.buy_funds = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_BUY_FUNDS, self.buy_funds))) self.sell_funds = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_SELL_FUNDS, self.sell_funds))) self.starting_price = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_STARTING_PRICE, self.starting_price))) self.sell_volume_per_order = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_SELL_VOLUME_PER_ORDER, self.sell_volume_per_order))) self.buy_volume_per_order = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_BUY_VOLUME_PER_ORDER, self.buy_volume_per_order))) self.limit_orders_count_if_necessary = \ self.symbol_trading_config.get(self.trading_mode.LIMIT_ORDERS_IF_NECESSARY, True) self.ignore_exchange_fees = self.symbol_trading_config.get(self.trading_mode.CONFIG_IGNORE_EXCHANGE_FEES, self.ignore_exchange_fees) self.use_existing_orders_only = self.symbol_trading_config.get(self.trading_mode.CONFIG_USE_EXISTING_ORDERS_ONLY, self.use_existing_orders_only) self.mirror_order_delay = self.symbol_trading_config.get(self.trading_mode.CONFIG_MIRROR_ORDER_DELAY, self.mirror_order_delay) self.allow_order_funds_redispatch = self.symbol_trading_config.get( self.trading_mode.CONFIG_ALLOW_FUNDS_REDISPATCH, self.allow_order_funds_redispatch ) if self.allow_order_funds_redispatch: # check every day that funds should not be redispatched and of orders are missing self.health_check_interval_secs = self.symbol_trading_config.get( self.trading_mode.CONFIG_FUNDS_REDISPATCH_INTERVAL, self.funds_redispatch_interval ) * commons_constants.HOURS_TO_SECONDS self.enable_trailing_up = self.symbol_trading_config.get( self.trading_mode.CONFIG_ENABLE_TRAILING_UP, self.enable_trailing_up ) self.enable_trailing_down = self.symbol_trading_config.get( self.trading_mode.CONFIG_ENABLE_TRAILING_DOWN, self.enable_trailing_down ) self.use_order_by_order_trailing = self.symbol_trading_config.get( self.trading_mode.CONFIG_ORDER_BY_ORDER_TRAILING, self.use_order_by_order_trailing ) self.compensate_for_missed_mirror_order = self.symbol_trading_config.get( self.trading_mode.COMPENSATE_FOR_MISSED_MIRROR_ORDER, self.use_order_by_order_trailing # use use_order_by_order_trailing as default value as compensate_for_missed_mirror_order is required for order by order trailing ) async def _handle_staggered_orders( self, current_price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing ): self._init_allowed_price_ranges(current_price) if ignore_mirror_orders_only or not self.use_existing_orders_only: async with self.producer_exchange_wide_lock(self.exchange_manager): if trigger_trailing and self.is_currently_trailing: self.logger.debug( f"{self.symbol} on {self.exchange_name}: trailing signal ignored: " f"a trailing process is already running" ) return # use exchange level lock to prevent funds double spend buy_orders, sell_orders, triggering_trailing, create_order_dependencies = await self._generate_staggered_orders( current_price, ignore_available_funds, trigger_trailing ) grid_orders = self._merged_and_sort_not_virtual_orders(buy_orders, sell_orders) await self._create_not_virtual_orders( grid_orders, current_price, triggering_trailing, create_order_dependencies ) if grid_orders: self._already_created_init_orders = True async def trigger_staggered_orders_creation(self): # reload configuration await self.trading_mode.reload_config(self.exchange_manager.bot_id) self._load_symbol_trading_config() self.read_config() if self.symbol_trading_config: await self._ensure_staggered_orders(ignore_mirror_orders_only=True, ignore_available_funds=True) else: self.logger.error(f"No configuration for {self.symbol}") def _load_symbol_trading_config(self) -> bool: if not super()._load_symbol_trading_config(): return self._apply_default_symbol_config() return True def _apply_default_symbol_config(self) -> bool: if not self.trading_mode.trading_config.get(commons_constants.ALLOW_DEFAULT_CONFIG, True): raise trading_errors.TradingModeIncompatibility( f"{self.trading_mode.get_name()} default configuration is not allowed. " f"Please configure the {self.symbol} settings." ) self.logger.info(f"Using default configuration for {self.symbol} as no configuration " f"is specified for this pair.") # set spread and increment as multipliers of the current price self.spread = decimal.Decimal(str(self.trading_mode.CONFIG_DEFAULT_SPREAD_PERCENT / 100)) self.increment = decimal.Decimal(str(self.trading_mode.CONFIG_DEFAULT_INCREMENT_PERCENT / 100)) self.symbol_trading_config = self.trading_mode.get_default_pair_config( self.symbol, None, # will compute flat_spread from self.spread None, # will compute flat_increment from self.increment None, None, None, None, None ) return True async def _generate_staggered_orders(self, current_price, ignore_available_funds, trigger_trailing): order_manager = self.exchange_manager.exchange_personal_data.orders_manager if not self.single_pair_setup: interfering_orders_pairs = self._get_interfering_orders_pairs(order_manager.get_open_orders()) if interfering_orders_pairs: self.logger.error( f"Impossible to create {self.symbol} grid orders with open orders on " f"{', '.join(interfering_orders_pairs)}. To use shared base or quote currencies, " f"set 'Total buy funds' and 'Total sell funds' in your {self.trading_mode.get_name()} " f"{self.symbol} configuration." ) return [], [], False, None existing_orders = order_manager.get_open_orders(self.symbol) sorted_orders = self._get_grid_trades_or_orders(existing_orders) oldest_existing_order_creation_time = min( order.creation_time for order in sorted_orders ) if sorted_orders else 0 recent_trades_time = max( trading_api.get_exchange_current_time( self.exchange_manager ) - self.RECENT_TRADES_ALLOWED_TIME, oldest_existing_order_creation_time ) # list of trades orders from the most recent one to the oldest one recently_closed_trades = sorted([ trade for trade in trading_api.get_trade_history( self.exchange_manager, symbol=self.symbol, since=recent_trades_time ) # non limit orders are not to be taken into account if trade.trade_type in (trading_enums.TraderOrderType.BUY_LIMIT, trading_enums.TraderOrderType.SELL_LIMIT) ], key=lambda t: -t.executed_time) lowest_buy = max(trading_constants.ZERO, self.buy_price_range.lower_bound) highest_buy = self.buy_price_range.higher_bound lowest_sell = self.sell_price_range.lower_bound highest_sell = self.sell_price_range.higher_bound if sorted_orders: if self._should_trigger_trailing(sorted_orders, current_price, False): trigger_trailing = True if self.use_order_by_order_trailing or not trigger_trailing: # grid boundaries are required in order by order trailing buy_orders = [order for order in sorted_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in sorted_orders if order.side == trading_enums.TradeOrderSide.SELL] highest_buy = current_price lowest_sell = current_price origin_created_buy_orders_count, origin_created_sell_orders_count = self._get_origin_orders_count( recently_closed_trades, sorted_orders ) min_max_total_order_price_delta = ( self.flat_increment * (origin_created_buy_orders_count - 1 + origin_created_sell_orders_count - 1) + self.flat_increment ) if buy_orders: lowest_buy = buy_orders[0].origin_price if not sell_orders: highest_buy = min(current_price, lowest_buy + min_max_total_order_price_delta) # buy orders only lowest_sell = highest_buy + self.flat_spread - self.flat_increment highest_sell = lowest_buy + min_max_total_order_price_delta + self.flat_spread - self.flat_increment else: # use only open order prices when possible _highest_sell = sell_orders[-1].origin_price highest_buy = min(current_price, _highest_sell - self.flat_spread + self.flat_increment) if sell_orders: highest_sell = sell_orders[-1].origin_price if not buy_orders: lowest_sell = max(current_price, highest_sell - min_max_total_order_price_delta) # sell orders only lowest_buy = max( 0, highest_sell - min_max_total_order_price_delta - self.flat_spread + self.flat_increment ) highest_buy = lowest_sell - self.flat_spread + self.flat_increment else: # use only open order prices when possible _lowest_buy = buy_orders[0].origin_price lowest_sell = max(current_price, _lowest_buy - self.flat_spread + self.flat_increment) next_step_dependencies = None trailing_buy_orders = trailing_sell_orders = [] confirmed_trailing = False # print(f"{self.use_order_by_order_trailing=}") if trigger_trailing: # trailing has no initial dependencies here _, __, trailing_buy_orders, trailing_sell_orders, next_step_dependencies = await self._prepare_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, None ) confirmed_trailing = True # trailing will cancel all orders: set state to NEW with no existing order missing_orders, state, sorted_orders = None, self.NEW, [] else: # no trailing, process normal analysis missing_orders, state, _ = self._analyse_current_orders_situation( sorted_orders, recently_closed_trades, lowest_buy, highest_sell, current_price ) if missing_orders: self.logger.info( f"{len(missing_orders)} missing {self.symbol} orders on {self.exchange_name}: {missing_orders}" ) elif sorted_orders: self.logger.info( f"All {len(sorted_orders)} out of {self.buy_orders_count + self.sell_orders_count} {self.symbol} " f"target orders are in place on {self.exchange_name}" ) await self._handle_missed_mirror_orders_fills(recently_closed_trades, missing_orders, current_price) try: if trailing_buy_orders or trailing_sell_orders: buy_orders = trailing_buy_orders sell_orders = trailing_sell_orders else: # apply state and (re)create missing orders buy_orders = self._create_orders(lowest_buy, highest_buy, trading_enums.TradeOrderSide.BUY, sorted_orders, current_price, missing_orders, state, self.buy_funds, ignore_available_funds, recently_closed_trades) sell_orders = self._create_orders(lowest_sell, highest_sell, trading_enums.TradeOrderSide.SELL, sorted_orders, current_price, missing_orders, state, self.sell_funds, ignore_available_funds, recently_closed_trades) if state is self.FILL and not confirmed_trailing: # don't check used funds if trailing is active to avoid cancelling trading self._ensure_used_funds(buy_orders, sell_orders, sorted_orders, recently_closed_trades) elif state is self.NEW: if trigger_trailing and not (buy_orders or sell_orders): self.logger.error(f"Unhandled situation: no orders created for {self.symbol} with trigger_trailing={trigger_trailing}") create_order_dependencies = next_step_dependencies except staggered_orders_trading.ForceResetOrdersException: lowest_buy = max(trading_constants.ZERO, self.buy_price_range.lower_bound) highest_buy = self.buy_price_range.higher_bound lowest_sell = self.sell_price_range.lower_bound highest_sell = self.sell_price_range.higher_bound buy_orders, sell_orders, state, create_order_dependencies = await self._reset_orders( sorted_orders, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, next_step_dependencies ) confirmed_trailing = False return buy_orders, sell_orders, confirmed_trailing, create_order_dependencies def _get_origin_orders_count(self, recent_trades, open_orders): origin_created_buy_orders_count = self.buy_orders_count origin_created_sell_orders_count = self.sell_orders_count if recent_trades: # in case all initial orders didn't get created, try to infer the original value from open orders and trades buy_orders_count = len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) buy_trades_count = len([trade for trade in recent_trades if trade.side is trading_enums.TradeOrderSide.BUY]) origin_created_buy_orders_count = buy_orders_count + buy_trades_count origin_created_sell_orders_count = ( len(open_orders) + len(recent_trades) - origin_created_buy_orders_count ) if origin_created_buy_orders_count + origin_created_sell_orders_count > self.buy_orders_count + self.sell_orders_count: # more orders than in config (usually because of trailing), use config values origin_created_buy_orders_count = self.buy_orders_count origin_created_sell_orders_count = self.sell_orders_count return origin_created_buy_orders_count, origin_created_sell_orders_count def _get_grid_trades_or_orders(self, trades_or_orders): if not trades_or_orders: return trades_or_orders sorted_elements = sorted(trades_or_orders, key=lambda t: self.get_trade_or_order_price(t)) four = decimal.Decimal("4") increment_lower_bound = - self.flat_increment / four increment_higher_bound = self.flat_increment / four filtered_out_orders = [] for first_element_index in range(len(sorted_elements)): grid_trades_or_orders = [] previous_element = None first_sided_element_price = None for trade_or_order in sorted_elements[first_element_index:]: if first_sided_element_price is None: first_sided_element_price = self.get_trade_or_order_price(trade_or_order) if previous_element is None: grid_trades_or_orders.append(trade_or_order) else: if trade_or_order.side != previous_element.side: # reached other side: take spread into account first_sided_element_price += self.flat_spread delta_increment = (self.get_trade_or_order_price(trade_or_order) - first_sided_element_price) \ % self.flat_increment if ( # delta is between -25%*increment and 25%*increment increment_lower_bound < delta_increment < increment_higher_bound ) or ( # delta is between 75%*increment and increment self.flat_increment - increment_higher_bound < delta_increment < self.flat_increment ): grid_trades_or_orders.append(trade_or_order) else: filtered_out_orders.append(trade_or_order) previous_element = trade_or_order if filtered_out_orders: self.logger.info( f"Filtered out {len(filtered_out_orders)} {self.symbol} non grid orders out of " f"{len(trades_or_orders)} [{self.exchange_manager.exchange_name}] orders" ) if len(grid_trades_or_orders) / len(sorted_elements) > 0.5: # make sure that we did not miss every grid trade by basing computations on a non grid trade # more than 50% match of grid trades: grid trades are found return grid_trades_or_orders # grid trades are not found, use every trade return sorted_elements def _init_allowed_price_ranges(self, current_price): self._set_increment_and_spread(current_price) first_sell_price = current_price + (self.flat_spread / 2) self.sell_price_range.higher_bound = first_sell_price + (self.flat_increment * (self.sell_orders_count - 1)) self.sell_price_range.lower_bound = max(current_price, first_sell_price) first_buy_price = current_price - (self.flat_spread / 2) self.buy_price_range.higher_bound = min(current_price, first_buy_price) self.buy_price_range.lower_bound = first_buy_price - (self.flat_increment * (self.buy_orders_count - 1)) def _check_params(self): if None not in (self.flat_increment, self.flat_spread) and self.flat_increment >= self.flat_spread: self.logger.error(f"Your flat_spread parameter should always be higher than your flat_increment" f" parameter: average profit is spread-increment. ({self.symbol})") def _create_new_orders_bundle( self, lower_bound, upper_bound, side, current_price, allowed_funds, ignore_available_funds, selling, order_limiting_currency, order_limiting_currency_amount ): orders = [] funds_to_use = self._get_maximum_traded_funds(allowed_funds, order_limiting_currency_amount, order_limiting_currency, selling, ignore_available_funds) if funds_to_use == 0: return [] starting_bound = lower_bound if selling else upper_bound self._create_new_orders(orders, current_price, selling, lower_bound, upper_bound, funds_to_use, order_limiting_currency, starting_bound, side, False, self.mode, order_limiting_currency_amount) return orders def _get_order_count_and_average_quantity(self, current_price, selling, lower_bound, upper_bound, holdings, currency, mode): if lower_bound >= upper_bound: self.logger.error(f"Invalid bounds for {self.symbol}: too close to the current price") return 0, 0 orders_count = self.sell_orders_count if selling else self.buy_orders_count if self._use_variable_orders_volume(trading_enums.TradeOrderSide.SELL if selling else trading_enums.TradeOrderSide.BUY): return self._ensure_average_order_quantity(orders_count, current_price, selling, holdings, currency, mode) else: return self._get_orders_count_from_fixed_volume(selling, current_price, holdings, orders_count) def _get_max_theoretical_orders_count(self): return self.buy_orders_count + self.sell_orders_count ================================================ FILE: Trading/Mode/grid_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["GridTradingMode"], "tentacles-requirements": ["staggered_orders_trading_mode"] } ================================================ FILE: Trading/Mode/grid_trading_mode/resources/GridTradingMode.md ================================================ Places a fixed amount of buy and sell orders at fixed intervals to profit from any market move. When an order is filled, a mirror order is instantly created and generates profit when completed. To know more, checkout the full Grid trading mode guide. #### Default configuration When left unspecified for a trading pair, the grid will be initialized with a spread of 1.5% of the current price and an increment of 0.5% and a maximum of 20 buy and sell orders. When enough funds are available, the default configuration will result in: - Up to 20 buy order covering 99.25% to 89.5% of the current price - Up to 20 sell orders covering 100.75% to 110.5% of the current price #### Trading pair configuration You can customize the grid for each trading pair. To configure a pair, enter: - The name of the pair - The interval between buy and sell (spread) - The interval between each order (increment) - The amount of initial buy and sell orders to create #### Trailing options A grid can only operate within its price range. However, when trailing options are enabled, the whole grid can be automatically cancelled and recreated when the traded asset's price moves beyond the grid range. In this case, a market order can be executed in order to have the necessary funds to create the grid buy and sell orders. #### Profits Profits will be made from price movements within the covered price area. It never "sells at a loss", but always at a profit, therefore OctoBot never cancels any orders when using the Grid Trading Mode. To apply changes to the Grid Trading Mode settings, you will have to manually cancel orders and restart your OctoBot. This trading mode instantly places opposite side orders when an order is filled. This trading mode has been made possible thanks to the support of PKBO & Calusari. _This trading mode supports PNL history._ ================================================ FILE: Trading/Mode/grid_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/grid_trading_mode/tests/open_orders_data.py ================================================ import json import octobot_trading.personal_data as personal_data import octobot_trading.storage as trading_storage async def get_full_sol_usdt_open_orders(exchange_manager) -> list[personal_data.Order]: order_data = json.loads(FULL_SOL_USDT_OPEN_ORDERS_DATA) pending_groups = {} orders = [] for o in order_data: orders.append(await personal_data.create_order_from_order_storage_details( trading_storage.orders_storage.from_order_document(o), exchange_manager, pending_groups )) return orders FULL_SOL_USDT_OPEN_ORDERS_DATA = """ [ { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.236395, "exchange_id": "5f72d5ab-e6d7-468f-9161-7d3c3150329a", "fee": null, "filled": 0, "id": "e2d61a49-f184-4cc8-9a1b-d1e2570346c5", "is_active": true, "price": 149.093, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362439.478, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.24154, "exchange_id": "7b010951-d6e5-4bb2-9494-df1ad09c8931", "fee": null, "filled": 0, "id": "b66efc84-a7ac-40a0-9504-8c6e1c6286f1", "is_active": true, "price": 149.436, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362439.993, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.246685, "exchange_id": "6d6e907b-33d6-4d54-b0cf-fa17b5e75ff9", "fee": null, "filled": 0, "id": "293e61bc-24b8-4dc1-a717-62137c66e2b2", "is_active": true, "price": 149.779, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362440.537, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.25183, "exchange_id": "55acfd16-05b0-4992-bd14-66214aaa5be0", "fee": null, "filled": 0, "id": "4afd159e-9ba2-4fd6-85ea-378be6610d9d", "is_active": true, "price": 150.122, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362441.365, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.256975, "exchange_id": "9f3c2566-2c27-46bf-8e6d-7fb20ec12269", "fee": null, "filled": 0, "id": "a5e1f296-92b0-4908-bd55-83022affb484", "is_active": true, "price": 150.465, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362442.069, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.26212, "exchange_id": "c6264220-3ab7-4d24-96c5-727c8634965c", "fee": null, "filled": 0, "id": "acbb27bd-8de6-4f5b-b53d-e2a529acceeb", "is_active": true, "price": 150.808, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362442.74, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.267265, "exchange_id": "bce0b649-e629-4f2c-90f7-83d4e5424653", "fee": null, "filled": 0, "id": "a7170333-1b62-40cc-b4e7-0acdc607c211", "is_active": true, "price": 151.151, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362443.241, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.272425, "exchange_id": "0527e79c-86bc-4721-9af6-e3d51af2ed00", "fee": null, "filled": 0, "id": "1733285b-6a7e-470e-b429-64751300bd81", "is_active": true, "price": 151.495, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362443.768, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.27757, "exchange_id": "56ba40bd-f94d-4d9b-8d72-1edea1ee5903", "fee": null, "filled": 0, "id": "34c8004b-0dbf-4930-97cf-ea369844ba1a", "is_active": true, "price": 151.838, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362444.591, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.282715, "exchange_id": "cbde43d2-c98b-4c6d-982c-653bdfd3b2ed", "fee": null, "filled": 0, "id": "1bdad673-d4c8-4d7b-973a-ac406e613433", "is_active": true, "price": 152.181, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362445.101, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.28786, "exchange_id": "023d3cf7-3e0b-41c5-b39d-bbce2070810e", "fee": null, "filled": 0, "id": "b0d75c90-ccc6-4e6f-9d3c-be89c7391239", "is_active": true, "price": 152.524, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362445.6, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.293005, "exchange_id": "26cd9f3e-63c4-40ff-a5a7-7d07fe2b1e9a", "fee": null, "filled": 0, "id": "4f40a061-cc2c-4bd5-a587-311064fbd886", "is_active": true, "price": 152.867, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362446.093, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.29815, "exchange_id": "3b158e17-5957-4f06-95e0-70dcf0e41fc8", "fee": null, "filled": 0, "id": "997fc0f4-6d1f-4549-9c49-6fc4ddeb1f3e", "is_active": true, "price": 153.21, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362446.587, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.303295, "exchange_id": "75bd9860-211f-4654-a699-0429c646c5ac", "fee": null, "filled": 0, "id": "160ff3c9-9c36-4f7e-a244-903bc07ceb77", "is_active": true, "price": 153.553, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362447.177, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.30844, "exchange_id": "64c40497-14f9-47f7-a1cd-52561d7b8e43", "fee": null, "filled": 0, "id": "72494344-7783-4bea-86d2-7def01b6943c", "is_active": true, "price": 153.896, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362447.668, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.313585, "exchange_id": "2dfd619b-0348-4510-bbfb-5d0a13ff7092", "fee": null, "filled": 0, "id": "33021a88-fc36-41c0-8bac-1ba7f5f41054", "is_active": true, "price": 154.239, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362448.162, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.31873, "exchange_id": "371c2f07-c976-4f43-a8e8-b55f5c6e6b56", "fee": null, "filled": 0, "id": "bc398948-e494-46ee-ac54-ce0321d73661", "is_active": true, "price": 154.582, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362452.3, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.32389, "exchange_id": "27ea1b0e-f4b7-4926-833c-0fe4aa1384a0", "fee": null, "filled": 0, "id": "028481c6-e2dd-4d27-b7ad-e8ac8948065b", "is_active": true, "price": 154.926, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362453.138, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.329035, "exchange_id": "449f2062-ff20-4343-b349-ef7bb5e0b463", "fee": null, "filled": 0, "id": "85565926-e992-41dd-a922-5528e9dc3cd6", "is_active": true, "price": 155.269, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362453.65, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.33418, "exchange_id": "0f3b10d0-9843-47fa-b9f8-0865bc3b1387", "fee": null, "filled": 0, "id": "c25e0844-f26e-4ab3-ae00-61d26310a9b6", "is_active": true, "price": 155.612, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362454.145, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.339325, "exchange_id": "74f4555d-5fe3-40e4-a0a4-6d7c9b3cd317", "fee": null, "filled": 0, "id": "fb8011f2-5ff4-4033-920f-d347c235050b", "is_active": true, "price": 155.955, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362454.64, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.34447, "exchange_id": "bf533d78-4f40-4626-aeab-fea7a4d1849e", "fee": null, "filled": 0, "id": "be9643ca-bb77-47e7-9ac4-b0b686dfa5d2", "is_active": true, "price": 156.298, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362455.132, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.349615, "exchange_id": "66613fa4-4d2c-4e6e-8908-05eda8ae8183", "fee": null, "filled": 0, "id": "ae535faa-e4dd-4702-81f3-520a35059f41", "is_active": true, "price": 156.641, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362455.629, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.35476, "exchange_id": "644cb5f8-dc8e-4ec0-a709-1d94901a2ac1", "fee": null, "filled": 0, "id": "ae1ba3d9-bdbd-404e-9d15-ba97670ec8a7", "is_active": true, "price": 156.984, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362456.128, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.359905, "exchange_id": "ce2d07d4-dde3-442b-923c-6490eeb5607d", "fee": null, "filled": 0, "id": "b597f05a-11bf-4cf4-80ae-942b9dae6284", "is_active": true, "price": 157.327, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "sell", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362456.625, "triggerAbove": true, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.22567, "exchange_id": "cad8be3a-23ed-4d5e-ac4f-880123393a42", "fee": null, "filled": 0, "id": "5c2eb7aa-49ac-4af5-b95b-f66d60f93197", "is_active": true, "price": 148.378, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362457.121, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.015, "broker_applied": false, "cost": 2.220525, "exchange_id": "19dc8ee4-0dbb-4d39-ae65-400fcde7c5d2", "fee": null, "filled": 0, "id": "bffaec37-383e-4fe1-ad9a-1514e23ee0a9", "is_active": true, "price": 148.035, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362457.628, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.363072, "exchange_id": "d77e3751-55cf-4769-ab86-14a855fd0993", "fee": null, "filled": 0, "id": "e885a482-4a5c-4b23-90eb-6ba44cea132f", "is_active": true, "price": 147.692, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362458.122, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.357584, "exchange_id": "debe8041-2485-4f95-b1ca-13ed5f398933", "fee": null, "filled": 0, "id": "3998e406-1f08-4c9b-9678-fb1bc256469a", "is_active": true, "price": 147.349, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362458.623, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.352096, "exchange_id": "0148bdf1-088c-4d69-8d70-be4d0785ae62", "fee": null, "filled": 0, "id": "3afaa44d-fba4-449a-9b1a-14b0cd2a654b", "is_active": true, "price": 147.006, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362459.14, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.346608, "exchange_id": "a9ff2a8e-b9b8-48c3-9813-e5b592c4e0e3", "fee": null, "filled": 0, "id": "c26fd512-b6c0-4bd4-90fd-3564e1c8f32c", "is_active": true, "price": 146.663, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362459.638, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.34112, "exchange_id": "039e1b2a-b531-420a-8057-dcc4323ed63e", "fee": null, "filled": 0, "id": "d0b68aea-521f-4019-9d71-554ccf3032a8", "is_active": true, "price": 146.32, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362460.135, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.335616, "exchange_id": "3e907299-7cf3-47c1-bcb3-9b047453e917", "fee": null, "filled": 0, "id": "dcdca84c-7636-4b4f-a32c-7c2f5f45ec10", "is_active": true, "price": 145.976, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362460.624, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.330128, "exchange_id": "0eaad5e3-5158-4c90-b002-1a01b3d55704", "fee": null, "filled": 0, "id": "172139ab-0841-4a02-b92a-6002fc31ba08", "is_active": true, "price": 145.633, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362462.833, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.32464, "exchange_id": "696abfb4-df1c-44cc-93c2-6512fd787feb", "fee": null, "filled": 0, "id": "cf223507-c436-4e65-94de-174e379d661d", "is_active": true, "price": 145.29, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362463.354, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.319152, "exchange_id": "ba69fedb-623b-40a1-be23-f2ba1037e77a", "fee": null, "filled": 0, "id": "2a9e57ed-531a-4649-a880-dff4513d9097", "is_active": true, "price": 144.947, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362463.864, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.313664, "exchange_id": "cf9b9e19-ff79-4800-b81d-a0eaa44b1e2c", "fee": null, "filled": 0, "id": "6473cef5-0f83-431d-9f7c-b7353456a484", "is_active": true, "price": 144.604, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362464.381, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.308176, "exchange_id": "77a8433a-6ff7-4b2d-a640-b5e4f592eb3e", "fee": null, "filled": 0, "id": "a0407fb5-599f-4d88-ae08-2d678ca375cb", "is_active": true, "price": 144.261, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362464.869, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.302688, "exchange_id": "0f9a422f-7f69-4c8a-8649-f451441d588f", "fee": null, "filled": 0, "id": "b8668c80-b436-4551-91b1-669fae2996e9", "is_active": true, "price": 143.918, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362465.362, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.2972, "exchange_id": "9f558a98-44c3-49d3-bb16-a75e5d294c24", "fee": null, "filled": 0, "id": "c6b4abd4-e419-424e-8726-5541856b7a8f", "is_active": true, "price": 143.575, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362465.858, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.291712, "exchange_id": "5c6a4c92-b7b3-425c-a37c-1ab16e85d955", "fee": null, "filled": 0, "id": "a1c1cf06-c020-4304-9f7c-f9c0c28938fc", "is_active": true, "price": 143.232, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362466.351, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.286224, "exchange_id": "868e5035-1cb9-413c-a8f8-5d08d2b97e77", "fee": null, "filled": 0, "id": "5960d7d7-f290-4a43-aa6c-16eaca5da57d", "is_active": true, "price": 142.889, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362466.848, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.28072, "exchange_id": "3c4ead8c-162a-4d70-ad75-a22ff8df2cdf", "fee": null, "filled": 0, "id": "4d3c13b0-6b6b-4aa4-b293-f9871b1acdb1", "is_active": true, "price": 142.545, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362467.354, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.275232, "exchange_id": "231ccb05-af47-4b39-a636-fe5dd8366421", "fee": null, "filled": 0, "id": "aa27b90c-cbbe-4a2a-a916-8e4302df21f4", "is_active": true, "price": 142.202, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362467.873, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.269744, "exchange_id": "99d4e79d-9dd5-49bd-ab2d-96e577e328e2", "fee": null, "filled": 0, "id": "5a1289dd-574b-445d-9d66-64761532be12", "is_active": true, "price": 141.859, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362468.366, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.264256, "exchange_id": "47751ce1-6a9b-4952-b334-5a08a791752c", "fee": null, "filled": 0, "id": "351587f1-cc99-43e0-9cf3-36d05ccb7ddc", "is_active": true, "price": 141.516, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362468.856, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.258768, "exchange_id": "00d8a607-ac35-4f09-a650-d8edbb7f9881", "fee": null, "filled": 0, "id": "a6f3b844-69bc-4fdc-beb7-26485fc055b8", "is_active": true, "price": 141.173, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362469.344, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.25328, "exchange_id": "9f0b79e3-8296-48d5-ad94-8ce4ddbce87e", "fee": null, "filled": 0, "id": "8c8437f0-b714-4963-8ed6-00017f83389c", "is_active": true, "price": 140.83, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362469.84, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.247792, "exchange_id": "2e028d69-8a07-4ba6-9da9-ef8b343a1d51", "fee": null, "filled": 0, "id": "4be47863-7c6a-4c87-8cd6-d8a20cb17870", "is_active": true, "price": 140.487, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362470.333, "triggerAbove": false, "type": "limit" } }, { "origin_value": { "amount": 0.016, "broker_applied": false, "cost": 2.242304, "exchange_id": "cfc91ba9-6440-4c4c-addf-a2973425e4cf", "fee": null, "filled": 0, "id": "bfc8bb3a-72e7-4572-9ebc-ffb43067a17b", "is_active": true, "price": 140.144, "quantity_currency": "SOL", "reduceOnly": false, "self-managed": false, "side": "buy", "status": "open", "symbol": "SOL/USDT", "tag": null, "takerOrMaker": "maker", "timestamp": 1751362470.852, "triggerAbove": false, "type": "limit" } } ] """ ================================================ FILE: Trading/Mode/grid_trading_mode/tests/test_grid_trading_mode.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import contextlib import numpy import pytest import os.path import asyncio import decimal import copy import mock import time import async_channel.util as channel_util import octobot_tentacles_manager.api as tentacles_manager_api import octobot_backtesting.api as backtesting_api import octobot_commons.constants as commons_constants import octobot_commons.tests.test_config as test_config import octobot_commons.asyncio_tools as asyncio_tools import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchanges as exchanges import octobot_trading.enums as trading_enums import octobot_trading.personal_data as trading_personal_data import octobot_trading.constants as trading_constants import octobot_trading.signals as trading_signals import octobot_trading.modes as trading_modes import tentacles.Trading.Mode.grid_trading_mode.grid_trading as grid_trading import tentacles.Trading.Mode.grid_trading_mode.tests.open_orders_data as open_orders_data import tentacles.Trading.Mode.staggered_orders_trading_mode.staggered_orders_trading as staggered_orders_trading import tests.test_utils.config as test_utils_config import tests.test_utils.memory_check_util as memory_check_util import tests.test_utils.test_exchanges as test_exchanges import tests.test_utils.trading_modes as test_trading_modes # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def _init_trading_mode(config, exchange_manager, symbol): staggered_orders_trading.StaggeredOrdersTradingModeProducer.SCHEDULE_ORDERS_CREATION_ON_START = False mode = grid_trading.GridTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol # mode.trading_config = _get_multi_symbol_staggered_config() await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) mode.producers[0].PRICE_FETCHING_TIMEOUT = 0.5 mode.producers[0].allow_order_funds_redispatch = True return mode, mode.producers[0] @contextlib.asynccontextmanager async def _get_tools(symbol, btc_holdings=None, additional_portfolio={}, fees=None): exchange_manager = None try: tentacles_manager_api.reload_tentacle_info() config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 1000 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "BTC"] = 10 if btc_holdings is None else btc_holdings config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO].update(additional_portfolio) if fees is not None: config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_SIMULATOR_FEES][ commons_constants.CONFIG_SIMULATOR_FEES_TAKER] = fees config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_SIMULATOR_FEES][ commons_constants.CONFIG_SIMULATOR_FEES_MAKER] = fees exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) mode, producer = await _init_trading_mode(config, exchange_manager, symbol) producer.flat_spread = decimal.Decimal(10) producer.flat_increment = decimal.Decimal(5) producer.buy_orders_count = 25 producer.sell_orders_count = 25 producer.compensate_for_missed_mirror_order = True test_trading_modes.set_ready_to_start(producer) yield producer, mode.get_trading_mode_consumers()[0], exchange_manager finally: if exchange_manager: await _stop(exchange_manager) async def _stop(exchange_manager): if exchange_manager is None: return for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() async def test_run_independent_backtestings_with_memory_check(): """ Should always be called first here to avoid other tests' related memory check issues """ staggered_orders_trading.StaggeredOrdersTradingModeProducer.SCHEDULE_ORDERS_CREATION_ON_START = True tentacles_setup_config = tentacles_manager_api.create_tentacles_setup_config_with_tentacles( grid_trading.GridTradingMode ) await memory_check_util.run_independent_backtestings_with_memory_check(test_config.load_test_config(), tentacles_setup_config) async def test_init_allowed_price_ranges_with_flat_values(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.sell_price_range = grid_trading.AllowedPriceRange() producer.buy_price_range = grid_trading.AllowedPriceRange() producer.flat_spread = decimal.Decimal(12) producer.flat_increment = decimal.Decimal(5) producer.sell_orders_count = 20 producer.buy_orders_count = 5 producer._init_allowed_price_ranges(100) # price + half spread + increment for each order to create after 1st one assert producer.sell_price_range.higher_bound == 100 + 12/2 + 5*(20-1) assert producer.sell_price_range.lower_bound == 100 + 12/2 assert producer.buy_price_range.higher_bound == 100 - 12/2 # price - half spread - increment for each order to create after 1st one assert producer.buy_price_range.lower_bound == 100 - 12/2 - 5*(5-1) async def test_init_allowed_price_ranges_with_percent_values(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.sell_price_range = grid_trading.AllowedPriceRange() producer.buy_price_range = grid_trading.AllowedPriceRange() # used with default configuration producer.spread = decimal.Decimal("0.05") # 5% producer.increment = decimal.Decimal("0.02") # 2% producer.flat_spread = None producer.flat_increment = None producer.sell_orders_count = 20 producer.buy_orders_count = 5 _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer._init_allowed_price_ranges(100) # price + half spread + increment for each order to create after 1st one assert producer.flat_spread == 5 assert producer.flat_increment == 2 assert producer.sell_price_range.higher_bound == decimal.Decimal(str(100 + 5/2 + 2*(20-1))) assert producer.sell_price_range.lower_bound == decimal.Decimal(str(100 + 5/2)) assert producer.buy_price_range.higher_bound == decimal.Decimal(str(100 - 5/2)) # price - half spread - increment for each order to create after 1st one assert producer.buy_price_range.lower_bound == decimal.Decimal(str(100 - 5/2 - 2*(5-1))) async def test_create_orders_with_default_config(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.spread = producer.increment = producer.flat_spread = producer.flat_increment = \ producer.buy_orders_count = producer.sell_orders_count = None producer.trading_mode.trading_config[producer.trading_mode.CONFIG_PAIR_SETTINGS] = [] assert producer._load_symbol_trading_config() is True producer.read_config() assert producer.spread is not None assert producer.increment is not None assert producer.flat_spread is None assert producer.flat_increment is None assert producer.buy_orders_count is not None assert producer.sell_orders_count is not None producer.sell_funds = decimal.Decimal("0.00006") # 5 orders producer.buy_funds = decimal.Decimal("1") # 24 orders # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() # create orders as with normal config (except that it's the default one) btc_available_funds = producer._get_available_funds("BTC") usd_available_funds = producer._get_available_funds("USDT") used_btc = 10 - btc_available_funds used_usd = 1000 - usd_available_funds assert producer.buy_funds * decimal.Decimal(0.95) <= used_usd <= producer.buy_funds assert producer.sell_funds * decimal.Decimal(0.95) <= used_btc <= producer.sell_funds # btc_available_funds for reduced because orders are not created assert 10 - 0.001 <= btc_available_funds < 10 assert 1000 - 100 <= usd_available_funds < 1000 await asyncio.create_task(_check_open_orders_count(exchange_manager, 5 + producer.buy_orders_count)) created_orders = trading_api.get_open_orders(exchange_manager) created_buy_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.BUY] created_sell_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.SELL] assert len(created_buy_orders) == producer.buy_orders_count == 20 assert len(created_sell_orders) < producer.sell_orders_count assert len(created_sell_orders) == 5 # ensure only orders closest to the current price have been created min_buy_price = 4000 - (producer.flat_spread / 2) - (producer.flat_increment * (len(created_buy_orders) - 1)) assert all( o.origin_price >= min_buy_price for o in created_buy_orders ) max_sell_price = 4000 + (producer.flat_spread / 2) + (producer.flat_increment * (len(created_sell_orders) - 1)) assert all( o.origin_price <= max_sell_price for o in created_sell_orders ) pf_btc_available_funds = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pf_usd_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pf_btc_available_funds >= 10 - 0.00006 assert pf_usd_available_funds >= 1000 - 1 assert pf_btc_available_funds >= btc_available_funds assert pf_usd_available_funds >= usd_available_funds async def test_create_orders_without_enough_funds_for_all_orders_16_total_orders(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.sell_funds = decimal.Decimal("0.00006") # 5 orders producer.buy_funds = decimal.Decimal("0.5") # 11 orders # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() btc_available_funds = producer._get_available_funds("BTC") usd_available_funds = producer._get_available_funds("USDT") used_btc = 10 - btc_available_funds used_usd = 1000 - usd_available_funds assert used_usd >= producer.buy_funds * decimal.Decimal(0.99) assert used_btc >= producer.sell_funds * decimal.Decimal(0.99) # btc_available_funds for reduced because orders are not created assert 10 - 0.001 <= btc_available_funds < 10 assert 1000 - 100 <= usd_available_funds < 1000 await asyncio.create_task(_check_open_orders_count(exchange_manager, 5 + 11)) created_orders = trading_api.get_open_orders(exchange_manager) created_buy_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.BUY] created_sell_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.SELL] assert len(created_buy_orders) < producer.buy_orders_count assert len(created_buy_orders) == 11 assert len(created_sell_orders) < producer.sell_orders_count assert len(created_sell_orders) == 5 # ensure only orders closest to the current price have been created min_buy_price = 4000 - (producer.flat_spread / 2) - (producer.flat_increment * (len(created_buy_orders) - 1)) assert all( o.origin_price >= min_buy_price for o in created_buy_orders ) max_sell_price = 4000 + (producer.flat_spread / 2) + (producer.flat_increment * (len(created_sell_orders) - 1)) assert all( o.origin_price <= max_sell_price for o in created_sell_orders ) pf_btc_available_funds = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pf_usd_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pf_btc_available_funds >= 10 - 0.00006 assert pf_usd_available_funds >= 1000 - 0.5 assert pf_btc_available_funds >= btc_available_funds assert pf_usd_available_funds >= usd_available_funds async def test_create_orders_without_enough_funds_for_all_orders_3_total_orders(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.buy_funds = decimal.Decimal("0.07") # 1 order producer.sell_funds = decimal.Decimal("0.000025") # 2 orders # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() btc_available_funds = producer._get_available_funds("BTC") usd_available_funds = producer._get_available_funds("USDT") used_btc = 10 - btc_available_funds used_usd = 1000 - usd_available_funds assert used_usd >= producer.buy_funds * decimal.Decimal(0.99) assert used_btc >= producer.sell_funds * decimal.Decimal(0.99) # btc_available_funds for reduced because orders are not created assert 10 - 0.001 <= btc_available_funds < 10 assert 1000 - 100 <= usd_available_funds < 1000 await asyncio.create_task(_check_open_orders_count(exchange_manager, 1 + 2)) created_orders = trading_api.get_open_orders(exchange_manager) created_buy_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.BUY] created_sell_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.SELL] assert len(created_buy_orders) < producer.buy_orders_count assert len(created_buy_orders) == 1 assert len(created_sell_orders) < producer.sell_orders_count assert len(created_sell_orders) == 2 # ensure only orders closest to the current price have been created min_buy_price = 4000 - (producer.flat_spread / 2) - (producer.flat_increment * (len(created_buy_orders) - 1)) assert all( o.origin_price >= min_buy_price for o in created_buy_orders ) max_sell_price = 4000 + (producer.flat_spread / 2) + (producer.flat_increment * (len(created_sell_orders) - 1)) assert all( o.origin_price <= max_sell_price for o in created_sell_orders ) pf_btc_available_funds = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pf_usd_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pf_btc_available_funds >= 10 - 0.000025 assert pf_usd_available_funds >= 1000 - 0.07 assert pf_btc_available_funds >= btc_available_funds assert pf_usd_available_funds >= usd_available_funds async def test_create_orders_with_fixed_volume_per_order(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.buy_volume_per_order = decimal.Decimal("0.1") producer.sell_volume_per_order = decimal.Decimal("0.3") # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, 27)) created_orders = trading_api.get_open_orders(exchange_manager) created_buy_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.BUY] created_sell_orders = [o for o in created_orders if o.side is trading_enums.TradeOrderSide.SELL] assert len(created_buy_orders) == 2 # not enough funds to create more orders assert len(created_sell_orders) == producer.sell_orders_count # 25 # ensure only closest orders got created with the right value and in the right order assert created_buy_orders[0].origin_price == 3995 assert created_buy_orders[1].origin_price == 3990 assert created_sell_orders[0].origin_price == 4005 assert created_sell_orders[1].origin_price == 4010 assert created_sell_orders[0] is created_orders[0] assert all(o.origin_quantity == producer.buy_volume_per_order for o in created_buy_orders) assert all(o.origin_quantity == producer.sell_volume_per_order for o in created_sell_orders) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 4000) async def test_start_with_existing_valid_orders(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders orders_count = 20 + 24 producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders (price is negative for the last 6 orders) orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count # new evaluation, same price price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() # did nothing await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert original_orders[0] is trading_api.get_open_orders(exchange_manager)[0] assert original_orders[-1] is trading_api.get_open_orders(exchange_manager)[-1] assert len(trading_api.get_open_orders(exchange_manager)) == orders_count first_buy_index = 25 # new evaluation, price changed # order would be filled to_fill_order = original_orders[first_buy_index] price = 95 assert price == to_fill_order.origin_price await _fill_order(to_fill_order, exchange_manager, price, producer=producer) await asyncio.create_task(_wait_for_orders_creation(2)) # did nothing: orders got replaced assert len(original_orders) == len(trading_api.get_open_orders(exchange_manager)) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() # did nothing assert len(original_orders) == len(trading_api.get_open_orders(exchange_manager)) # orders gets cancelled open_orders = trading_api.get_open_orders(exchange_manager) to_cancel = [open_orders[20], open_orders[18], open_orders[3]] for order in to_cancel: await exchange_manager.trader.cancel_order(order) post_available = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(to_cancel) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(orders_count)) # restored orders assert len(trading_api.get_open_orders(exchange_manager)) == orders_count assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_filled_orders_without_recent_trades(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("10000") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation 1: orders get filled but not replaced => price got up to 110 and down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if 90 <= o.origin_price <= 110] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) # clear trades await trading_api.clear_trades_storage_history(exchange_manager) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price price = 96 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) # offline simulation 2: orders get filled but not replaced => price got down to 50 pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available price = 50 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if price <= o.origin_price <= 100] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) # clear trades await trading_api.clear_trades_storage_history(exchange_manager) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_portfolio _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_filled_orders_with_recent_trades(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("10000") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if 90 <= o.origin_price <= 110] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price price = 95 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) # offline simulation 2: orders get filled but not replaced => price got down to 50 pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available price = 50 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if price <= o.origin_price <= 100] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_portfolio _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_filled_orders_close_to_price_with_recent_trades_considering_fees(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("10000") # 25 buy orders producer.flat_spread = decimal.Decimal("200") producer.flat_increment = decimal.Decimal("75") producer.ignore_exchange_fees = False orders_count = 25 + 25 initial_price = 29247.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29147.16')] assert len(offline_filled_orders) == 1 offline_filled = offline_filled_orders[0] await _fill_order(offline_filled, exchange_manager, trigger_update_callback=False, producer=producer) # offline_filled is a buy order: now have mode BTC post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - 1 # back online: restore orders according to current price => create sell missing order price = 29127.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled_orders)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available < post_portfolio open_orders = trading_api.get_open_orders(exchange_manager) _check_created_orders(producer, open_orders, initial_price) new_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29272.16')] assert len(new_orders) == 1 new_order = new_orders[0] assert new_order.side is trading_enums.TradeOrderSide.SELL # offline_filled - fees trade = trading_api.get_trade_history(exchange_manager)[0] fees = trade.fee[trading_enums.FeePropertyColumns.COST.value] symbol_market = exchange_manager.exchange.get_market_status(symbol, with_fixer=False) assert new_order.origin_quantity == \ trading_personal_data.decimal_adapt_quantity(symbol_market, offline_filled.origin_quantity - fees) async def test_start_after_offline_filled_orders_close_to_price_with_recent_trades_ignoring_fees_with_enough_available_funds(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("10000") # 25 buy orders producer.flat_spread = decimal.Decimal("200") producer.flat_increment = decimal.Decimal("75") producer.ignore_exchange_fees = True orders_count = 25 + 25 initial_price = 29247.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29147.16')] assert len(offline_filled_orders) == 1 offline_filled = offline_filled_orders[0] await _fill_order(offline_filled, exchange_manager, trigger_update_callback=False, producer=producer) # offline_filled is a buy order: now have mode BTC post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - 1 # back online: restore orders according to current price => create sell missing order price = 29127.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled_orders)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available < post_portfolio open_orders = trading_api.get_open_orders(exchange_manager) _check_created_orders(producer, open_orders, initial_price) new_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29272.16')] assert len(new_orders) == 1 new_order = new_orders[0] assert new_order.side is trading_enums.TradeOrderSide.SELL # offline_filled - fees trade = trading_api.get_trade_history(exchange_manager)[0] fees = trade.fee[trading_enums.FeePropertyColumns.COST.value] assert fees > trading_constants.ZERO assert new_order.origin_quantity == offline_filled.origin_quantity # trading fees exist but are not taken into account async def test_start_after_offline_filled_orders_close_to_price_with_recent_trades_ignoring_fees_without_enough_available_sell_funds(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("10000") # 25 buy orders producer.flat_spread = decimal.Decimal("200") producer.flat_increment = decimal.Decimal("75") producer.ignore_exchange_fees = True orders_count = 25 + 25 initial_price = 29247.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29147.16')] assert len(offline_filled_orders) == 1 offline_filled = offline_filled_orders[0] assert offline_filled.side is trading_enums.TradeOrderSide.BUY await _fill_order(offline_filled, exchange_manager, trigger_update_callback=False, producer=producer) # offline_filled is a buy order: now have mode BTC post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - 1 assert offline_filled.origin_quantity == decimal.Decimal("0.00136765") trading_api.get_portfolio_currency(exchange_manager, "BTC").available = decimal.Decimal("0.00116765111111111111111111111") # less than order quantity to simulate fees # back online: restore orders according to current price => create missing sell order price = 29127.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled_orders)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available < post_portfolio open_orders = trading_api.get_open_orders(exchange_manager) _check_created_orders(producer, open_orders, initial_price) new_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29272.16')] assert len(new_orders) == 1 new_order = new_orders[0] assert new_order.side is trading_enums.TradeOrderSide.SELL # offline_filled - fees trade = trading_api.get_trade_history(exchange_manager)[0] fees = trade.fee[trading_enums.FeePropertyColumns.COST.value] assert fees > trading_constants.ZERO assert new_order.origin_quantity < offline_filled.origin_quantity # adapted amount to available funds assert new_order.origin_quantity == decimal.Decimal("0.00116765") async def test_start_after_offline_filled_orders_close_to_price_with_recent_trades_ignoring_fees_without_enough_available_buy_funds(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("10000") # 25 buy orders producer.flat_spread = decimal.Decimal("200") producer.flat_increment = decimal.Decimal("75") producer.ignore_exchange_fees = True orders_count = 25 + 25 initial_price = 29247.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29347.16')] assert len(offline_filled_orders) == 1 offline_filled = offline_filled_orders[0] assert offline_filled.side is trading_enums.TradeOrderSide.SELL await _fill_order(offline_filled, exchange_manager, trigger_update_callback=False, producer=producer) offline_filled_cost = offline_filled.total_cost assert offline_filled_cost == decimal.Decimal("1173.8864") # offline_filled is a buy order: now have mode BTC USDT_assets = trading_api.get_portfolio_currency(exchange_manager, "USDT") USDT_assets.available = decimal.Decimal("666") # less than order quantity to simulate fees post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - 1 assert offline_filled.origin_quantity == decimal.Decimal("0.04") # back online: restore orders according to current price => create missing buy order price = 29227.16 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled_orders)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available < post_portfolio open_orders = trading_api.get_open_orders(exchange_manager) _check_created_orders(producer, open_orders, initial_price) new_orders = [o for o in open_orders if o.origin_price == decimal.Decimal('29222.16')] assert len(new_orders) == 1 new_order = new_orders[0] assert new_order.side is trading_enums.TradeOrderSide.BUY # offline_filled - fees trade = trading_api.get_trade_history(exchange_manager)[0] fees = trade.fee[trading_enums.FeePropertyColumns.COST.value] assert fees > trading_constants.ZERO assert new_order.origin_quantity < offline_filled.origin_quantity # adapted amount to available funds assert new_order.origin_quantity == decimal.Decimal("0.02210719") assert new_order.total_cost == decimal.Decimal("646.0198433304") # < 666 async def test_start_after_offline_full_sell_side_filled_orders_with_recent_trades(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price price = max(order.origin_price for order in offline_filled) * 2 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() assert producer.operational_depth > orders_count await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available open_orders = trading_api.get_open_orders(exchange_manager) assert all( order.side == trading_enums.TradeOrderSide.BUY for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_full_sell_side_filled_orders_price_back(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to average origin sell orders price = offline_filled[len(offline_filled)//2].origin_price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) def _get_fees_for_currency(fee, currency): if currency == "USDT": return decimal.Decimal("0.022") return trading_constants.ZERO with _assert_missing_orders_count(producer, len(offline_filled)): with _assert_adapt_order_quantity_because_fees(_get_fees_for_currency) \ as adapt_order_quantity_because_fees_mock: await producer._ensure_staggered_orders() adapt_order_quantity_because_fees_mock.assert_called_once_with( producer.exchange_manager, producer.trading_mode.symbol, trading_enums.TraderOrderType.BUY_MARKET, decimal.Decimal('0.25714721'), decimal.Decimal('165'), trading_enums.TradeOrderSide.BUY, ) # restored orders (and create up to 50 orders as all orders can be created) assert producer.operational_depth > orders_count await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available open_orders = trading_api.get_open_orders(exchange_manager) assert not all( order.side == trading_enums.TradeOrderSide.BUY for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_full_buy_side_filled_orders_price_back_with_recent_trades(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to average origin buy orders price = offline_filled[len(offline_filled)//2].origin_price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_portfolio open_orders = trading_api.get_open_orders(exchange_manager) assert not all( order.side == trading_enums.TradeOrderSide.BUY for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_buy_side_10_filled(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY][:10] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to average origin buy orders price = offline_filled[len(offline_filled)//2].origin_price + 1 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): with _assert_adapt_order_quantity_because_fees(None) \ as adapt_order_quantity_because_fees_mock: await producer._ensure_staggered_orders() adapt_order_quantity_because_fees_mock.assert_called_once_with( producer.exchange_manager, producer.trading_mode.symbol, trading_enums.TraderOrderType.SELL_MARKET, decimal.Decimal('0.00320847831'), decimal.Decimal('71'), trading_enums.TradeOrderSide.SELL, ) # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # created 5 more sell orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 + 5 # restored 5 of the 10 filled buy orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 19 - 5 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) async def test_start_after_offline_x_filled_and_price_back_should_sell_to_recreate_buy(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): orders_count = 25 + 25 price = decimal.Decimal(200) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price moved to 150 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY and o.origin_price >= decimal.Decimal("150") ] # this is 10 orders assert len(offline_filled) == 10 max_filled_order_price = max(o.origin_price for o in offline_filled) assert max_filled_order_price == decimal.Decimal(195) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # buy orders filled: BTC increased assert pre_btc_portfolio < post_btc_portfolio # no sell order filled, available USDT is constant assert pre_usdt_portfolio == post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to 180: should quickly sell BTC bought between 150 and 180 to be able to # create buy orders between 150 and 180 price = decimal.Decimal(180) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_btc_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # created 4 additional sell orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 + 4 # restored 6 out of 10 filled buy orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 25 - 10 + 6 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) async def test_start_after_offline_1_filled_and_price_back_should_NOT_sell_to_recreate_buy_but_just_create_a_sell_order(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): orders_count = 25 + 25 price = decimal.Decimal(200) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: 1 buy order get filled but not replaced => price moved to 194 (first buy order is at 195) open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY and o.origin_price >= decimal.Decimal("194") ] assert len(offline_filled) == 1 assert offline_filled[0].origin_price == decimal.Decimal(195) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # buy orders filled: BTC increased assert pre_btc_portfolio < post_btc_portfolio # no sell order filled, available USDT is constant assert pre_usdt_portfolio == post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to 198: do not market sell BTC but create a new sell order instead price = decimal.Decimal(198) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) # lower sell order is at 205 assert min(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL) == decimal.Decimal(205) with _assert_missing_orders_count(producer, len(offline_filled)): with mock.patch.object(producer, "_pack_and_balance_missing_orders", mock.AsyncMock()) as _pack_and_balance_missing_orders_mock: await producer._ensure_staggered_orders() # does not create missing mirror orders market orders _pack_and_balance_missing_orders_mock.assert_not_called() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_btc_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # created 1 additional sell order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 + 1 # created a new sell order at 200 assert min(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL) == decimal.Decimal(200) # no created buy order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 25 - 1 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) async def test_start_after_offline_1_filled_and_price_back_should_NOT_sell_to_recreate_buy_but_just_create_a_sell_order_with_surrounding_partially_filled_orders(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): orders_count = 25 + 25 price = decimal.Decimal(200) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: 1 buy order get filled but not replaced => price moved to 194 (first buy order is at 195) # and 2nd buy order get partially filled open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY and o.origin_price >= decimal.Decimal("194") ] assert len(offline_filled) == 1 assert offline_filled[0].origin_price == decimal.Decimal(195) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # buy orders filled: BTC increased assert pre_btc_portfolio < post_btc_portfolio # no sell order filled, available USDT is constant assert pre_usdt_portfolio == post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) partially_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY and o.origin_price == decimal.Decimal("190") or o.origin_price == decimal.Decimal("205") ] assert len(partially_filled) == 2 for partially_filled_order in partially_filled: partially_filled_order.filled_quantity = partially_filled_order.origin_quantity / decimal.Decimal(2) partially_filled_order.filled_price = partially_filled_order.origin_price # add trade corresponding to the partial order fill assert await exchange_manager.exchange_personal_data.handle_trade_instance_update( exchange_manager.trader.convert_order_to_trade(partially_filled_order) ) is True trade = exchange_manager.exchange_personal_data.trades_manager.get_trade_from_order_id(partially_filled_order.order_id) assert trade.executed_quantity == partially_filled_order.filled_quantity assert trade.executed_price == partially_filled_order.origin_price trade.executed_time = time.time() # these trades are the most recent ones # back online: restore orders according to current price # simulate current price as back to 198: do not market sell BTC but create a new sell order instead price = decimal.Decimal(198) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) # lower sell order is at 205 assert min(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL) == decimal.Decimal(205) with _assert_missing_orders_count(producer, len(offline_filled)): with mock.patch.object(producer, "_pack_and_balance_missing_orders", mock.AsyncMock()) as _pack_and_balance_missing_orders_mock: await producer._ensure_staggered_orders() # does not create missing mirror orders market orders _pack_and_balance_missing_orders_mock.assert_not_called() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available <= post_btc_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # created 1 additional sell order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 + 1 # created a new sell order at 200 assert min(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL) == decimal.Decimal(200) # no created buy order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 25 - 1 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) async def test_start_after_offline_1_filled_and_price_back_should_NOT_buy_to_recreate_sell_but_just_create_a_buy_order(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): orders_count = 25 + 25 price = decimal.Decimal(200) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: 1 sell order get filled but not replaced => price moved to 206 (first sell order is at 205) open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL and o.origin_price <= decimal.Decimal("206") ] assert len(offline_filled) == 1 assert offline_filled[0].origin_price == decimal.Decimal(205) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # sell orders filled: BTC is constant assert pre_btc_portfolio == post_btc_portfolio # no sell order filled, USDT increased assert pre_usdt_portfolio < post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to 202: do not market sell BTC but create a new buy order instead price = decimal.Decimal(202) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) # higest buy order is at 195 assert max(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY) == decimal.Decimal(195) with _assert_missing_orders_count(producer, len(offline_filled)): with mock.patch.object(producer, "_pack_and_balance_missing_orders", mock.AsyncMock()) as _pack_and_balance_missing_orders_mock: await producer._ensure_staggered_orders() # does not create missing mirror orders market orders _pack_and_balance_missing_orders_mock.assert_not_called() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_usdt_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # no created sell order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 - 1 assert min(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL) == decimal.Decimal(210) # created a new buy order at 200 assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 25 + 1 assert max(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY) == decimal.Decimal(200) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) async def test_start_after_offline_2_filled_and_price_back_should_buy_to_recreate_sell(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): orders_count = 25 + 25 price = decimal.Decimal(200) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: 2 sell orders get filled but not replaced => price moved to 211 (first sell order is at 211) open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL and o.origin_price <= decimal.Decimal("211") ] assert len(offline_filled) == 2 assert offline_filled[0].origin_price == decimal.Decimal(205) assert offline_filled[1].origin_price == decimal.Decimal(210) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # sell orders filled: BTC is constant assert pre_btc_portfolio == post_btc_portfolio # no sell order filled, USDT increased assert pre_usdt_portfolio < post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to 202: do not market sell BTC but create a new buy order instead price = decimal.Decimal(202) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) # higest buy order is at 195 assert max(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY) == decimal.Decimal(195) with _assert_missing_orders_count(producer, len(offline_filled)): with mock.patch.object(producer, "_pack_and_balance_missing_orders", mock.AsyncMock(wraps=producer._pack_and_balance_missing_orders)) as _pack_and_balance_missing_orders_mock: await producer._ensure_staggered_orders() # DOES create a missing mirror orders market order to compensate for the missing sell order _pack_and_balance_missing_orders_mock.assert_called_once() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_usdt_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # recreated 1 sell order at 210 assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 - 1 assert min(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL) == decimal.Decimal(210) # created a new buy order at 200 assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 25 + 1 assert max(order.origin_price for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY) == decimal.Decimal(200) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) async def test_start_after_offline_x_filled_and_price_back_should_buy_to_recreate_sell(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): orders_count = 25 + 25 price = decimal.Decimal(200) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price moved to 150 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL and o.origin_price <= decimal.Decimal("250") ] # this is 10 orders assert len(offline_filled) == 10 max_filled_order_price = max(o.origin_price for o in offline_filled) assert max_filled_order_price == decimal.Decimal(250) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # buy orders filled: available BTC is constant assert pre_btc_portfolio == post_btc_portfolio # no sell order filled, available USDT increased assert pre_usdt_portfolio <= post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as back to 220: should quickly buy BTC sold between 250 and 220 to be able to # create sell orders between 220 and 250 price = decimal.Decimal(220) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_usdt_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # restored 6 out of 10 sell orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 25 - 10 + 6 # created 4 additional buy orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 25 + 4 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) async def test_start_after_offline_x_filled_and_missing_should_recreate_1_sell(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # forced config producer.buy_funds = producer.sell_funds = 0 producer.allow_order_funds_redispatch = True producer.buy_orders_count = producer.sell_orders_count = 5 producer.compensate_for_missed_mirror_order = True producer.enable_trailing_down = False producer.enable_trailing_up = True producer.flat_increment = decimal.Decimal(100) producer.flat_spread = decimal.Decimal(300) producer.reinvest_profits = False producer.sell_volume_per_order = producer.buy_volume_per_order = False producer.starting_price = 0 producer.use_existing_orders_only = False orders_count = producer.buy_orders_count + producer.sell_orders_count initial_price = decimal.Decimal("105278.1") trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) btc_pf = trading_api.get_portfolio_currency(exchange_manager, "BTC") usdt_pf = trading_api.get_portfolio_currency(exchange_manager, "USDT") btc_pf.available = decimal.Decimal("0.00141858") btc_pf.total = decimal.Decimal("0.00141858") usdt_pf.available = decimal.Decimal("150.505098") usdt_pf.total = decimal.Decimal("150.505098") await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count assert sorted([ order.origin_price for order in original_orders ]) == [ # buy orders decimal.Decimal('104728.1'), decimal.Decimal('104828.1'), decimal.Decimal('104928.1'), decimal.Decimal('105028.1'), decimal.Decimal('105128.1'), # sell orders decimal.Decimal('105428.1'), decimal.Decimal('105528.1'), decimal.Decimal('105628.1'), decimal.Decimal('105728.1'), decimal.Decimal('105828.1') ] # price goes down to 105120, 105128.1 order gets filled price = decimal.Decimal("105120") # offline simulation: price goes down to 105120, 105128.1 order gets filled offline_filled = [order for order in original_orders if order.origin_price == decimal.Decimal('105128.1')] assert len(offline_filled) == 1 assert offline_filled[0].side == trading_enums.TradeOrderSide.BUY for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) assert btc_pf.available == decimal.Decimal('0.00028861409') assert btc_pf.total == decimal.Decimal('0.00170420409') assert usdt_pf.available == decimal.Decimal('0.247225519') assert usdt_pf.total == decimal.Decimal('120.447922929') # back online: restore orders according to current price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) open_orders = trading_api.get_open_orders(exchange_manager) # there is now 6 sell orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 6 # there is now 4 buy orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 4 # quantity is preserved assert all( decimal.Decimal("0.00028") < order.origin_quantity < decimal.Decimal("0.00029") for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), initial_price) async def test_start_after_offline_x_filled_and_missing_should_recreate_5_sell_orders_no_recent_trade(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # forced config producer.buy_funds = producer.sell_funds = 0 producer.allow_order_funds_redispatch = True producer.buy_orders_count = producer.sell_orders_count = 5 producer.compensate_for_missed_mirror_order = True producer.enable_trailing_down = False producer.enable_trailing_up = True producer.flat_increment = decimal.Decimal(100) producer.flat_spread = decimal.Decimal(300) producer.reinvest_profits = False producer.sell_volume_per_order = producer.buy_volume_per_order = False producer.starting_price = 0 producer.use_existing_orders_only = False orders_count = producer.buy_orders_count + producer.sell_orders_count initial_price = decimal.Decimal("105278.1") trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) btc_pf = trading_api.get_portfolio_currency(exchange_manager, "BTC") usdt_pf = trading_api.get_portfolio_currency(exchange_manager, "USDT") btc_pf.available = decimal.Decimal("0.00141858") btc_pf.total = decimal.Decimal("0.00141858") usdt_pf.available = decimal.Decimal("150.505098") usdt_pf.total = decimal.Decimal("150.505098") await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count assert sorted([ order.origin_price for order in original_orders ]) == [ # buy orders decimal.Decimal('104728.1'), decimal.Decimal('104828.1'), decimal.Decimal('104928.1'), decimal.Decimal('105028.1'), decimal.Decimal('105128.1'), # sell orders decimal.Decimal('105428.1'), decimal.Decimal('105528.1'), decimal.Decimal('105628.1'), decimal.Decimal('105728.1'), decimal.Decimal('105828.1') ] # price goes down to 104720, all buy order get filled price = decimal.Decimal("104720") offline_filled = [order for order in original_orders if order.origin_price <= decimal.Decimal('105128.1')] assert len(offline_filled) == 5 assert all(o.side == trading_enums.TradeOrderSide.BUY for o in offline_filled) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) assert btc_pf.available == decimal.Decimal("0.00143356799") assert btc_pf.total == decimal.Decimal("0.00284915799") assert usdt_pf.available == decimal.Decimal("0.247225519") assert usdt_pf.total == decimal.Decimal("0.247225519") # clear trades exchange_manager.exchange_personal_data.trades_manager.trades.clear() # back online: restore orders according to current price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # create buy orders equivalent sell orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) open_orders = trading_api.get_open_orders(exchange_manager) # there is now 10 sell orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 10 # quantity is preserved assert all( decimal.Decimal("0.00028") < order.origin_quantity < decimal.Decimal("0.00029") for order in open_orders ) # there is now 0 buy order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 0 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), initial_price) assert btc_pf.available == decimal.Decimal("0.00001571799") assert btc_pf.total == decimal.Decimal("0.00284915799") assert usdt_pf.available == decimal.Decimal("0.247225519") assert usdt_pf.total == decimal.Decimal("0.247225519") async def test_start_after_offline_x_filled_and_missing_should_recreate_5_buy_orders_no_recent_trade(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # forced config producer.buy_funds = producer.sell_funds = 0 producer.allow_order_funds_redispatch = True producer.buy_orders_count = producer.sell_orders_count = 5 producer.compensate_for_missed_mirror_order = True producer.enable_trailing_down = False producer.enable_trailing_up = True producer.flat_increment = decimal.Decimal(100) producer.flat_spread = decimal.Decimal(300) producer.reinvest_profits = False producer.sell_volume_per_order = producer.buy_volume_per_order = False producer.starting_price = 0 producer.use_existing_orders_only = False orders_count = producer.buy_orders_count + producer.sell_orders_count initial_price = decimal.Decimal("105278.1") trading_api.force_set_mark_price(exchange_manager, producer.symbol, initial_price) btc_pf = trading_api.get_portfolio_currency(exchange_manager, "BTC") usdt_pf = trading_api.get_portfolio_currency(exchange_manager, "USDT") btc_pf.available = decimal.Decimal("0.00141858") btc_pf.total = decimal.Decimal("0.00141858") usdt_pf.available = decimal.Decimal("150.505098") usdt_pf.total = decimal.Decimal("150.505098") await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count assert sorted([ order.origin_price for order in original_orders ]) == [ # buy orders decimal.Decimal('104728.1'), decimal.Decimal('104828.1'), decimal.Decimal('104928.1'), decimal.Decimal('105028.1'), decimal.Decimal('105128.1'), # sell orders decimal.Decimal('105428.1'), decimal.Decimal('105528.1'), decimal.Decimal('105628.1'), decimal.Decimal('105728.1'), decimal.Decimal('105828.1') ] # price goes up to 105838, all sell order get filled price = decimal.Decimal("105838") offline_filled = [order for order in original_orders if order.origin_price > decimal.Decimal('105128.1')] assert len(offline_filled) == 5 assert all(o.side == trading_enums.TradeOrderSide.SELL for o in offline_filled) for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) assert btc_pf.available == decimal.Decimal("0.00000299") assert btc_pf.total == decimal.Decimal("0.00000299") assert usdt_pf.available == decimal.Decimal("149.623458838921") assert usdt_pf.total == decimal.Decimal("299.881331319921") # clear trades exchange_manager.exchange_personal_data.trades_manager.trades.clear() # back online: restore orders according to current price trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() # create buy orders equivalent sell orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) open_orders = trading_api.get_open_orders(exchange_manager) # there is now 0 sell order assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 0 # there is now 10 buy orders assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 10 # quantity is preserved assert all( decimal.Decimal("0.00028") < order.origin_quantity < decimal.Decimal("0.00029") for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), initial_price) async def test_start_after_offline_1_filled_should_create_buy_considering_fees(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): price = decimal.Decimal("26616.7") producer.flat_spread = decimal.Decimal(275) producer.flat_increment = decimal.Decimal(125) producer.buy_orders_count = 30 producer.sell_orders_count = 30 producer.ignore_exchange_fees = False orders_count = producer.buy_orders_count + producer.sell_orders_count trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price moved to 26756.2 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL and o.origin_price <= decimal.Decimal("26756.2") ] # this is 1 order assert len(offline_filled) == 1 assert offline_filled[0].origin_price == decimal.Decimal("26754.2") for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # buy orders filled: available BTC is constant assert pre_btc_portfolio == post_btc_portfolio # no sell order filled, available USDT increased assert pre_usdt_portfolio <= post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price at 26753.8 price = decimal.Decimal("26753.8") trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, 1): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_usdt_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # 1 sell order is filled assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 30 - 1 # 1 buy order is added assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 30 + 1 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), decimal.Decimal("26616.7")) async def test_start_after_offline_1_filled_should_create_buy_ignoring_fees(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): price = decimal.Decimal("26616.7") producer.flat_spread = decimal.Decimal(275) producer.flat_increment = decimal.Decimal(125) producer.buy_orders_count = 30 producer.sell_orders_count = 30 producer.ignore_exchange_fees = True orders_count = producer.buy_orders_count + producer.sell_orders_count trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pre_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price moved to 26756.2 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL and o.origin_price <= decimal.Decimal("26756.2") ] # this is 1 order assert len(offline_filled) == 1 assert offline_filled[0].origin_price == decimal.Decimal("26754.2") for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_btc_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available post_usdt_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # buy orders filled: available BTC is constant assert pre_btc_portfolio == post_btc_portfolio # no sell order filled, available USDT increased assert pre_usdt_portfolio <= post_usdt_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price at 26753.8 price = decimal.Decimal("26753.8") trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, 1): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_usdt_portfolio open_orders = trading_api.get_open_orders(exchange_manager) # 1 sell order is filled assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 30 - 1 # 1 buy order is added assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 30 + 1 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), decimal.Decimal("26616.7")) async def test_start_after_offline_1_filled_should_create_sell(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): price = decimal.Decimal("26616.7") producer.flat_spread = decimal.Decimal(275) producer.flat_increment = decimal.Decimal(125) producer.buy_orders_count = 30 producer.sell_orders_count = 30 orders_count = producer.buy_orders_count + producer.sell_orders_count trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count # offline simulation: orders get filled but not replaced => price moved to 26756.2 open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY and o.origin_price >= decimal.Decimal("26459.2") ] # this is 1 order assert len(offline_filled) == 1 assert offline_filled[0].origin_price == decimal.Decimal("26479.2") for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price at 26409.2 price = decimal.Decimal("26409.2") trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) with _assert_missing_orders_count(producer, 1): await producer._ensure_staggered_orders() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) open_orders = trading_api.get_open_orders(exchange_manager) # 1 sell order is filled assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.SELL]) == 30 + 1 # 1 buy order is added assert len([order for order in open_orders if order.side is trading_enums.TradeOrderSide.BUY]) == 30 - 1 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), decimal.Decimal("26616.7")) async def test_start_after_offline_with_added_funds_increasing_orders_count(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, consumer, exchange_manager): producer.sell_funds = decimal.Decimal("0.00005") # 4 sell orders producer.buy_funds = decimal.Decimal("0.005") # 4 buy orders # first start: setup orders orders_count = 4 + 4 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count initial_buy_orders_average_cost = numpy.mean( [o.total_cost for o in original_orders if o.side == trading_enums.TradeOrderSide.BUY] ) initial_sell_orders_average_cost = numpy.mean( [o.total_cost for o in original_orders if o.side == trading_enums.TradeOrderSide.SELL] ) previous_orders = original_orders # 1. offline simulation: nothing happens: orders are not replaced with _assert_missing_orders_count(producer, 0): await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) assert all(order.is_open() for order in previous_orders) # 2. offline simulation: funds are added (here config changed) producer.sell_funds = decimal.Decimal("0.0001") # 9 sell orders # triggering orders will cancel all open orders and recreate grid orders with new funds with mock.patch.object( consumer, "create_order", mock.AsyncMock(wraps=consumer.create_order) ) as create_order_mock, mock.patch.object( producer.trading_mode, "cancel_order", mock.AsyncMock(wraps=producer.trading_mode.cancel_order) ) as cancel_order_mock: await producer._ensure_staggered_orders() # one more buy order assert cancel_order_mock.call_count == orders_count # all orders are cancelled assert all( call.kwargs["dependencies"] is None for call in cancel_order_mock.mock_calls ) new_orders_count = orders_count + 5 await asyncio.create_task(_check_open_orders_count(exchange_manager, new_orders_count)) assert create_order_mock.call_count == new_orders_count cancelled_orders_dependencies = trading_signals.get_orders_dependencies( [call.args[0] for call in cancel_order_mock.mock_calls] ) # cancel orders dependencies are forwarded as dependencies for newly created orders assert all( call.args[3] == cancelled_orders_dependencies for call in create_order_mock.mock_calls ) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == new_orders_count # replaced orders assert new_orders[0] is not original_orders[0] assert all(order.is_cancelled() for order in original_orders) updated_buy_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.BUY] ) updated_sell_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.SELL] ) # use approx same order size assert initial_buy_orders_average_cost * decimal.Decimal(str(0.9)) < \ updated_buy_orders_average_cost < \ initial_buy_orders_average_cost * decimal.Decimal(str(1.1)) assert initial_sell_orders_average_cost * decimal.Decimal(str(0.9)) < \ updated_sell_orders_average_cost < \ initial_sell_orders_average_cost * decimal.Decimal(str(1.1)) # 3. offline simulation: funds are added (here config changed) producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("0.01") # 9 sell orders # triggering orders will cancel all open orders and recreate grid orders with new funds await producer._ensure_staggered_orders() # one more buy order new_orders_count = 34 await asyncio.create_task(_check_open_orders_count(exchange_manager, new_orders_count)) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == new_orders_count # replaced orders assert new_orders[0] is not original_orders[0] assert all(order.is_cancelled() for order in original_orders) async def test_start_after_offline_with_added_funds_increasing_order_sizes(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count initial_buy_orders_average_cost = numpy.mean( [o.total_cost for o in original_orders if o.side == trading_enums.TradeOrderSide.BUY] ) initial_sell_orders_average_cost = numpy.mean( [o.total_cost for o in original_orders if o.side == trading_enums.TradeOrderSide.SELL] ) # offline simulation: funds are added def _increase_funds(asset, multiplier): asset.available = asset.available + asset.total * decimal.Decimal(str(multiplier - 1)) asset.total = asset.total * decimal.Decimal(str(multiplier)) return asset portfolio = exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio portfolio["BTC"] = _increase_funds(portfolio["BTC"], 2) portfolio["USDT"] = _increase_funds(portfolio["USDT"], 4) # triggering orders will cancel all open orders and recreate grid orders with new funds await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == orders_count # replaced orders assert new_orders[0] is not original_orders[0] assert all(order.is_cancelled() for order in original_orders) updated_buy_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.BUY] ) updated_sell_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.SELL] ) assert initial_buy_orders_average_cost * decimal.Decimal(str(3.5)) < \ updated_buy_orders_average_cost < \ initial_buy_orders_average_cost * decimal.Decimal(str(4.5)) assert initial_sell_orders_average_cost * decimal.Decimal(str(1.5)) < \ updated_sell_orders_average_cost < \ initial_sell_orders_average_cost * decimal.Decimal(str(2.5)) # increase again (2x BTC) portfolio["BTC"] = _increase_funds(portfolio["BTC"], 2) previous_orders = new_orders await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == orders_count # replaced orders assert new_orders[0] is not previous_orders[0] assert all(order.is_cancelled() for order in previous_orders) updated_buy_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.BUY] ) updated_sell_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.SELL] ) assert initial_buy_orders_average_cost * decimal.Decimal(str(3.5)) < \ updated_buy_orders_average_cost < \ initial_buy_orders_average_cost * decimal.Decimal(str(4.5)) assert initial_sell_orders_average_cost * decimal.Decimal(str(1.5)) * decimal.Decimal(2) \ < updated_sell_orders_average_cost < \ initial_sell_orders_average_cost * decimal.Decimal(str(2.5)) * decimal.Decimal(2) # increase again (1.1x BTC) portfolio["BTC"] = _increase_funds(portfolio["BTC"], decimal.Decimal("1.1")) previous_orders = new_orders await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == orders_count # did not replace orders funds increase is not significant enough assert new_orders[0] is previous_orders[0] assert all(order.is_open() for order in previous_orders) # increase again (12x USDT) portfolio["USDT"] = _increase_funds(portfolio["USDT"], decimal.Decimal("12")) previous_orders = new_orders producer.allow_order_funds_redispatch = False await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == orders_count # did not replace orders: allow_order_funds_redispatch is False assert new_orders[0] is previous_orders[0] assert all(order.is_open() for order in previous_orders) producer.allow_order_funds_redispatch = True # fill orders before check pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available offline_filled = [o for o in new_orders if o.side == trading_enums.TradeOrderSide.BUY][:2] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) with _assert_missing_orders_count(producer, len(offline_filled)): await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(new_orders) == orders_count # replaced orders assert new_orders[0] is not previous_orders[0] assert all(order.is_cancelled() for order in previous_orders if order not in offline_filled) updated_buy_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.BUY] ) updated_sell_orders_average_cost = numpy.mean( [o.total_cost for o in new_orders if o.side == trading_enums.TradeOrderSide.SELL] ) assert initial_buy_orders_average_cost * decimal.Decimal(str(3.5)) * decimal.Decimal(12) < \ updated_buy_orders_average_cost < \ initial_buy_orders_average_cost * decimal.Decimal(str(4.5)) * decimal.Decimal(12) assert initial_sell_orders_average_cost * decimal.Decimal(str(1.5)) * decimal.Decimal(2) \ < updated_sell_orders_average_cost < \ initial_sell_orders_average_cost * decimal.Decimal(str(2.5)) * decimal.Decimal(2) async def test_start_after_offline_only_buy_orders_remaining(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as still too high price = offline_filled[-1].origin_price * decimal.Decimal("1.5") trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) def _get_fees_for_currency(fee, currency): if currency == "USDT": return decimal.Decimal("0.022") return trading_constants.ZERO with _assert_missing_orders_count(producer, len(offline_filled)): with _assert_adapt_order_quantity_because_fees(_get_fees_for_currency) \ as adapt_order_quantity_because_fees_mock: await producer._ensure_staggered_orders() await asyncio_tools.wait_asyncio_next_cycle() assert adapt_order_quantity_because_fees_mock.call_count == 25 # restored orders (and create up to 50 orders as all orders can be created) assert producer.operational_depth > orders_count await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) # did not replace orders: replace should not happen new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert sorted(new_orders, key=lambda x: x.origin_price)[0] is sorted(open_orders, key=lambda x: x.origin_price)[0] assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert all( order.side == trading_enums.TradeOrderSide.BUY for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) # trigger again with _assert_missing_orders_count(producer, 50 - 25 - 19): with _assert_adapt_order_quantity_because_fees(_get_fees_for_currency) \ as adapt_order_quantity_because_fees_mock: await producer._ensure_staggered_orders() await asyncio_tools.wait_asyncio_next_cycle() assert adapt_order_quantity_because_fees_mock.call_count == 50 - 25 - 19 # filled the grid with orders up to operational depth (50) orders_count = 50 await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) # did not replace orders: replace should not happen new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert sorted(new_orders, key=lambda x: x.origin_price)[0] is sorted(open_orders, key=lambda x: x.origin_price)[0] async def test_start_after_offline_only_sell_orders_remaining(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) # back online: restore orders according to current price # simulate current price as still too high price = decimal.Decimal("0.01") trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) def _get_fees_for_currency(fee, currency): if currency == "USDT": return decimal.Decimal("0.02") return trading_constants.ZERO with _assert_missing_orders_count(producer, len(offline_filled)): with _assert_adapt_order_quantity_because_fees(_get_fees_for_currency) \ as adapt_order_quantity_because_fees_mock: await producer._ensure_staggered_orders() await asyncio_tools.wait_asyncio_next_cycle() assert adapt_order_quantity_because_fees_mock.call_count == 19 # restored orders (and create up to 50 orders as all orders can be created) assert producer.operational_depth > orders_count await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) # did not replace orders: replace should not happen new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert sorted(new_orders, key=lambda x: x.origin_price)[-1] is sorted(open_orders, key=lambda x: x.origin_price)[-1] assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available < post_portfolio open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert all( order.side == trading_enums.TradeOrderSide.SELL for order in open_orders ) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) # trigger again with _assert_missing_orders_count(producer, 1): with _assert_adapt_order_quantity_because_fees(_get_fees_for_currency) \ as adapt_order_quantity_because_fees_mock: await producer._ensure_staggered_orders() await asyncio_tools.wait_asyncio_next_cycle() assert adapt_order_quantity_because_fees_mock.call_count == 1 # filled the grid with orders up to operational depth (45 as no sell order can be created bellow $5) orders_count = 45 await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) # did not replace orders: replace should not happen new_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert sorted(new_orders, key=lambda x: x.origin_price)[-1] is sorted(open_orders, key=lambda x: x.origin_price)[-1] async def test_start_after_offline_no_missing_order(): symbol = "SOL/USDT" async with _get_tools(symbol) as (producer, _, exchange_manager): producer.buy_funds = trading_constants.ZERO producer.sell_funds = trading_constants.ZERO producer.flat_spread = decimal.Decimal('0.714792') producer.flat_increment = decimal.Decimal('0.34310016') producer.buy_orders_count = 25 producer.sell_orders_count = 25 producer.enable_trailing_up = True producer.enable_trailing_down = False producer.use_existing_orders_only = False producer.funds_redispatch_interval = 24 producer.use_existing_orders_only = False producer.ignore_exchange_fees = True pre_portfolio_usdt = trading_api.get_portfolio_currency(exchange_manager, "USDT") pre_portfolio_sol = trading_api.get_portfolio_currency(exchange_manager, "SOL") pre_portfolio_usdt.total = decimal.Decimal("59.25023354") pre_portfolio_usdt.available = pre_portfolio_usdt.total pre_portfolio_sol.total = decimal.Decimal("0.397005") pre_portfolio_sol.available = pre_portfolio_sol.total price = 148.736 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) open_orders = await open_orders_data.get_full_sol_usdt_open_orders(exchange_manager) for order in open_orders: await order.initialize() await exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(order) with mock.patch.object(producer, "_create_not_virtual_orders", mock.Mock()) as _create_not_virtual_orders_mock: await producer._ensure_staggered_orders() assert _create_not_virtual_orders_mock.call_count == 1 assert _create_not_virtual_orders_mock.mock_calls[0].args[0] == [] # should not find any missing order and should not trail async def test_whole_grid_trailing_up_and_down(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, consumer, exchange_manager): producer.use_order_by_order_trailing = False # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("1") # 19 buy orders orders_count = 19 + 25 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100) # A. price moves up pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) producer.enable_trailing_up = True # top filled sell order price = 225 assert max(o.origin_price for o in offline_filled) == decimal.Decimal("225") new_price = decimal.Decimal(250) trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price) # will trail up with mock.patch.object( consumer, "create_order", mock.AsyncMock(wraps=consumer.create_order) ) as create_order_mock, mock.patch.object( producer.trading_mode, "cancel_order", mock.AsyncMock(wraps=producer.trading_mode.cancel_order) ) as cancel_order_mock, mock.patch.object( trading_modes, "convert_asset_to_target_asset", mock.AsyncMock(wraps=trading_modes.convert_asset_to_target_asset) ) as convert_asset_to_target_asset_mock: await producer._ensure_staggered_orders() assert cancel_order_mock.call_count == 19 # all buy orders are cancelled assert all( call.kwargs["dependencies"] is None for call in cancel_order_mock.mock_calls ) cancelled_orders_dependencies = trading_signals.get_orders_dependencies( [call.args[0] for call in cancel_order_mock.mock_calls] ) convert_asset_to_target_asset_mock.assert_not_called() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 250) assert create_order_mock.call_count == producer.operational_depth # no conversion, will use cancel order dependencies assert all( call.args[3] == cancelled_orders_dependencies for call in create_order_mock.mock_calls ) # B. orders get filled but not enough to trigger a trailing reset # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = trading_api.get_open_orders(exchange_manager) # all but 1 sell orders is filled offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL][:-1] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(offline_filled) producer.enable_trailing_up = True producer.enable_trailing_down = True # doesn't trail up: a sell order still remains await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 250) # all buy orders are still here # not cancelled sell order is still here offline_filled_ids = [o.order_id for o in offline_filled] for order in open_orders: if order.order_id in offline_filled_ids: assert order.is_closed() else: assert order.is_open() # C. price moves down, trailing down is disabled pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(offline_filled) producer.enable_trailing_down = False # top filled sell order price = 125 assert min(o.origin_price for o in offline_filled) == decimal.Decimal("125") new_price = decimal.Decimal(125) trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price) # will not trail down await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 250) # only contains sell orders open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert all (order.side == trading_enums.TradeOrderSide.SELL for order in open_orders) # D. price is still down, trailing down is enabled producer.enable_trailing_down = True # will trail down await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth - 1)) # -1 because the very first order can't be at a price <0 # orders are recreated around 125 _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 125) # now contains buy and sell orders open_orders = trading_api.get_open_orders(exchange_manager) assert len([order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL]) == producer.sell_orders_count assert len([order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY]) == producer.buy_orders_count - 1 async def test_order_by_order_trailing_up_and_down(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, consumer, exchange_manager): producer.use_order_by_order_trailing = True # first start: setup orders producer.sell_funds = decimal.Decimal("1") # 25 sell orders producer.buy_funds = decimal.Decimal("200") # 25 buy orders orders_count = 25 + 25 price = 200 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == orders_count _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 200) # A. price moves up pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) producer.enable_trailing_up = True # top filled sell order price = 325 assert max(o.origin_price for o in offline_filled) == decimal.Decimal("325") new_price = decimal.Decimal("350.1") trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price) _convert_asset_to_target_asset_returned_values = [] async def _convert_asset_to_target_asset(*args, **kwargs): returned = await origin_convert_asset_to_target_asset(*args, **kwargs) _convert_asset_to_target_asset_returned_values.append(returned) return returned origin_convert_asset_to_target_asset = trading_modes.convert_asset_to_target_asset # will trail up with mock.patch.object( consumer, "create_order", mock.AsyncMock(wraps=consumer.create_order) ) as create_order_mock, mock.patch.object( producer.trading_mode, "cancel_order", mock.AsyncMock(wraps=producer.trading_mode.cancel_order) ) as cancel_order_mock, mock.patch.object( trading_modes, "convert_asset_to_target_asset", mock.AsyncMock(side_effect=_convert_asset_to_target_asset) ) as convert_asset_to_target_asset_mock: await producer._ensure_staggered_orders() new_buy_order_prices_to_create = [ decimal.Decimal("325"), decimal.Decimal("330"), decimal.Decimal("335"), decimal.Decimal("340"), decimal.Decimal("345"), ] cancelled_orders_prices = [ # replaced by new buy orders decimal.Decimal("75"), decimal.Decimal("80"), decimal.Decimal("85"), decimal.Decimal("90"), decimal.Decimal("95"), # converted to BTC for the trailed sell order decimal.Decimal("100"), ] assert cancel_order_mock.call_count == len(cancelled_orders_prices) assert sorted( call.args[0].origin_price for call in cancel_order_mock.mock_calls ) == cancelled_orders_prices assert all( call.kwargs["dependencies"] is None for call in cancel_order_mock.mock_calls ) cancelled_orders_dependencies = trading_signals.get_orders_dependencies( [call.args[0] for call in cancel_order_mock.mock_calls] ) convert_asset_to_target_asset_mock.assert_awaited_once_with( producer.trading_mode, "USDT", "BTC", { producer.symbol: { trading_enums.ExchangeConstantsTickersColumns.CLOSE.value: new_price, } }, asset_amount=decimal.Decimal("7.7922"), dependencies=cancelled_orders_dependencies ) convert_dependencies = trading_signals.get_orders_dependencies( _convert_asset_to_target_asset_returned_values[-1] ) await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 230) assert create_order_mock.call_count == 25 + len(new_buy_order_prices_to_create) + 1 # replaced initial sell orders and created trailing buy orders + the "other side" order assert sorted( call.args[0].price for call in create_order_mock.mock_calls ) == sorted( [ # replaced sell orders decimal.Decimal(str(i)) for i in range(200, 325, 5) ] # trailed orders + new_buy_order_prices_to_create # "other side" order + [decimal.Decimal("355")] ) # no conversion, will use cancel order dependencies assert all( call.args[3] == convert_dependencies for call in create_order_mock.mock_calls ) open_orders = trading_api.get_open_orders(exchange_manager) # ensure 1 sell order is open and the rest are buy orders sell_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] assert len(sell_orders) == 1 assert sell_orders[0].origin_price == decimal.Decimal("355") buy_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] assert len(buy_orders) == orders_count - 1 assert sorted( o.origin_price for o in buy_orders ) == [ decimal.Decimal(str(i)) for i in range(105, 350, 5) # 105 to 345 ] # B. single sell orders get filled, trail again # offline simulation: buy orders get filled but not replaced open_orders = trading_api.get_open_orders(exchange_manager) # since 1 sell orders is filled offline_filled = [ o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL ] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) producer.enable_trailing_up = True producer.enable_trailing_down = True new_price = decimal.Decimal("360.1") trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 240) open_orders = trading_api.get_open_orders(exchange_manager) # ensure 1 sell order is open and the rest are buy orders sell_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] assert len(sell_orders) == 1 assert sell_orders[0].origin_price == decimal.Decimal("365") assert sell_orders[0].origin_quantity == decimal.Decimal("0.02136986") buy_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] assert len(buy_orders) == orders_count - 1 assert sorted( o.origin_price for o in buy_orders ) == [ decimal.Decimal(str(i)) for i in range(115, 360, 5) # 115 to 355 ] # C. price moves down, trailing down is disabled pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # offline simulation: orders get filled but not replaced => price got up to more than the max price open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) producer.enable_trailing_down = False # top filled sell order price = 125 assert min(o.origin_price for o in offline_filled) == decimal.Decimal("115") new_price = decimal.Decimal("114.9") trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price) # will not trail down await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 240) # only contains sell orders open_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert all (order.side == trading_enums.TradeOrderSide.SELL for order in open_orders) # D. price is still down, trailing down is enabled producer.enable_trailing_down = True # will trail down await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) # orders trailed _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 235) # now contains buy and sell orders open_orders = trading_api.get_open_orders(exchange_manager) # ensure 1 buy order is open and the rest are sell orders sell_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] assert len(sell_orders) == orders_count - 1 assert sorted( o.origin_price for o in sell_orders ) == [ decimal.Decimal(str(i)) for i in range(120, 365, 5) # 120 to 360 (previous buy orders got replaced by sell orders at price+spread-increment) ] buy_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] assert len(buy_orders) == 1 assert buy_orders[0].origin_price == decimal.Decimal("110") assert buy_orders[0].origin_quantity == decimal.Decimal("0.07090908") # E. price is down much more, trail down on multiple orders new_price = decimal.Decimal("82") offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY and o.origin_price > decimal.Decimal("82")] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled) trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count)) # orders trailed _check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 205) # now contains buy and sell orders open_orders = trading_api.get_open_orders(exchange_manager) # ensure 1 buy order is open and the rest are sell orders sell_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] assert len(sell_orders) == orders_count - 1 assert sorted( o.origin_price for o in sell_orders ) == [ decimal.Decimal(str(i)) for i in range(90, 335, 5) # 90 to 330 ] buy_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] assert len(buy_orders) == 1 assert buy_orders[0].origin_price == decimal.Decimal("80") assert buy_orders[0].origin_quantity == decimal.Decimal("0.09887323") @contextlib.contextmanager def _assert_adapt_order_quantity_because_fees(get_fees_for_currency=False): _origin_decimal_adapt_order_quantity_because_fees = trading_personal_data.decimal_adapt_order_quantity_because_fees with mock.patch.object( trading_personal_data, "decimal_adapt_order_quantity_because_fees", mock.Mock(side_effect=_origin_decimal_adapt_order_quantity_because_fees) ) as decimal_adapt_order_quantity_because_fees_mock: if get_fees_for_currency is None: yield decimal_adapt_order_quantity_because_fees_mock else: with mock.patch.object( trading_personal_data, "get_fees_for_currency", mock.Mock(side_effect=get_fees_for_currency) ): yield decimal_adapt_order_quantity_because_fees_mock @contextlib.contextmanager def _assert_missing_orders_count(trading_mode_producer, expected_count): origin_analyse_current_orders_situation = trading_mode_producer._analyse_current_orders_situation missing_orders = [] def _local_analyse_current_orders_situation(*args, **kwargs): return_vals = origin_analyse_current_orders_situation(*args, **kwargs) created_missing_orders = return_vals[0] for order in created_missing_orders: missing_orders.append(order) return return_vals with mock.patch.object(trading_mode_producer, "_analyse_current_orders_situation", mock.Mock( side_effect=_local_analyse_current_orders_situation )) as _local_analyse_current_orders_situation_mock: yield _local_analyse_current_orders_situation_mock.assert_called_once() assert len(missing_orders) == expected_count async def _wait_for_orders_creation(orders_count=1): for _ in range(orders_count): await asyncio_tools.wait_asyncio_next_cycle() async def _check_open_orders_count(exchange_manager, count): await _wait_for_orders_creation(count) assert len(trading_api.get_open_orders(exchange_manager)) == count async def _fill_order(order, exchange_manager, trigger_update_callback=True, producer=None): initial_len = len(trading_api.get_open_orders(exchange_manager)) await order.on_fill(force_fill=True) if order.status == trading_enums.OrderStatus.FILLED: assert len(trading_api.get_open_orders(exchange_manager)) == initial_len - 1 if trigger_update_callback: # Wait twice so allow `await asyncio_tools.wait_asyncio_next_cycle()` in order.initialize() to finish and complete # order creation AND roll the next cycle that will wake up any pending portfolio lock and allow it to # proceed (here `filled_order_state.terminate()` can be locked if an order has been previously filled AND # a mirror order is being created (and its `await asyncio_tools.wait_asyncio_next_cycle()` in order.initialize() # is pending: in this case `AbstractTradingModeConsumer.create_order_if_possible()` is still # locking the portfolio cause of the previous order's `await asyncio_tools.wait_asyncio_next_cycle()`)). # This lock issue can appear here because we don't use `asyncio_tools.wait_asyncio_next_cycle()` after mirror order # creation (unlike anywhere else in this test file). for _ in range(2): await asyncio_tools.wait_asyncio_next_cycle() else: with mock.patch.object(producer, "order_filled_callback", new=mock.AsyncMock()): await asyncio_tools.wait_asyncio_next_cycle() def _check_created_orders(producer, orders, initial_price): previous_order = None sorted_orders = sorted(orders, key=lambda o: o.origin_price) for order in sorted_orders: # price if previous_order: if previous_order.side == order.side: assert order.origin_price == previous_order.origin_price + producer.flat_increment else: assert order.origin_price == previous_order.origin_price + producer.flat_spread previous_order = order min_price = max( 0, decimal.Decimal(str(initial_price)) - producer.flat_spread / 2 - (producer.flat_increment * (producer.buy_orders_count - 1)) ) max_price = decimal.Decimal(str(initial_price)) + producer.flat_spread / 2 + \ (producer.flat_increment * (producer.sell_orders_count - 1)) assert min_price <= sorted_orders[0].origin_price <= max_price, ( f"min_price: {min_price}, {sorted_orders[0].origin_price=}, max_price: {max_price}" ) assert min_price <= sorted_orders[-1].origin_price <= max_price, ( f"min_price: {min_price}, {sorted_orders[-1].origin_price=}, max_price: {max_price}" ) ================================================ FILE: Trading/Mode/index_trading_mode/__init__.py ================================================ from .index_trading import IndexTradingMode ================================================ FILE: Trading/Mode/index_trading_mode/config/IndexTradingMode.json ================================================ { "required_strategies": [], "refresh_interval": 1, "rebalance_trigger_min_percent": 5, "index_content": [] } ================================================ FILE: Trading/Mode/index_trading_mode/index_distribution.py ================================================ import decimal import typing import numpy import octobot_trading.constants DISTRIBUTION_NAME = "name" DISTRIBUTION_VALUE = "value" MAX_DISTRIBUTION_AFTER_COMMA_DIGITS = 1 def get_uniform_distribution(coins) -> typing.List: if not coins: return [] ratio = float( round( octobot_trading.constants.ONE / decimal.Decimal(str(len(coins))) * octobot_trading.constants.ONE_HUNDRED, MAX_DISTRIBUTION_AFTER_COMMA_DIGITS ) ) if not ratio: return [] return [ { DISTRIBUTION_NAME: coin, DISTRIBUTION_VALUE: ratio } for coin in coins ] def get_linear_distribution(weight_by_coin: dict[str, decimal.Decimal]) -> typing.List: total_weight = sum(weight for weight in weight_by_coin.values()) if total_weight <= octobot_trading.constants.ZERO: raise ValueError(f"total weight is {total_weight}") return [ { DISTRIBUTION_NAME: coin, DISTRIBUTION_VALUE: float(round( weight / total_weight * octobot_trading.constants.ONE_HUNDRED, MAX_DISTRIBUTION_AFTER_COMMA_DIGITS )) } for coin, weight in weight_by_coin.items() ] def get_smoothed_distribution(weight_by_coin: dict[str, decimal.Decimal]) -> typing.List: return get_linear_distribution({ coin: decimal.Decimal(str(numpy.cbrt(float(weight)))) for coin, weight in weight_by_coin.items() }) ================================================ FILE: Trading/Mode/index_trading_mode/index_trading.py ================================================ # Drakkar-Software OctoBot # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import decimal import enum import typing import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.authentication as authentication import octobot_commons.signals as commons_signals import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot_trading.modes as trading_modes import octobot_trading.util as trading_util import octobot_trading.personal_data as trading_personal_data import octobot_trading.signals as signals import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution class IndexActivity(enum.Enum): REBALANCING_DONE = "rebalancing_done" REBALANCING_SKIPPED = "rebalancing_skipped" class RebalanceSkipDetails(enum.Enum): ALREADY_BALANCED = "already_balanced" NOT_ENOUGH_AVAILABLE_FOUNDS = "not_enough_available_founds" class RebalanceDetails(enum.Enum): SELL_SOME = "SELL_SOME" BUY_MORE = "BUY_MORE" REMOVE = "REMOVE" ADD = "ADD" SWAP = "SWAP" FORCED_REBALANCE = "FORCED_REBALANCE" class SynchronizationPolicy(enum.Enum): SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE = "sell_removed_index_coins_on_ratio_rebalance" SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE = "sell_removed_index_coins_as_soon_as_possible" class RebalanceAborted(Exception): pass DEFAULT_QUOTE_ASSET_REBALANCE_TRIGGER_MIN_RATIO = 0.1 # 10% DEFAULT_REBALANCE_TRIGGER_MIN_RATIO = 0.05 # 5% class IndexTradingModeConsumer(trading_modes.AbstractTradingModeConsumer): FILL_ORDER_TIMEOUT = 60 SIMPLE_ADD_MIN_TOLERANCE_RATIO = decimal.Decimal("0.8") # 20% tolerance def __init__(self, trading_mode): super().__init__(trading_mode) self._already_logged_aborted_rebalance_error = False async def create_new_orders(self, symbol, _, state, **kwargs): details = kwargs[self.CREATE_ORDER_DATA_PARAM] dependencies = kwargs.get(self.CREATE_ORDER_DEPENDENCIES_PARAM, None) if state == trading_enums.EvaluatorStates.NEUTRAL.value: try: self.trading_mode.is_processing_rebalance = True return await self._rebalance_portfolio(details, dependencies) finally: self.trading_mode.is_processing_rebalance = False self.logger.error(f"Unknown index state: {state}") return [] async def _rebalance_portfolio(self, details: dict, initial_dependencies: typing.Optional[commons_signals.SignalDependencies]): self.logger.info(f"Executing rebalance on [{self.exchange_manager.exchange_name}]") orders = [] try: # 1. make sure we can actually rebalance the portfolio self.logger.info("Step 1/3: ensuring enough funds are available for rebalance") await self._ensure_enough_funds_to_buy_after_selling() # 2. sell indexed coins for reference market is_simple_buy_without_selling = self._can_simply_buy_coins_without_selling(details) sell_orders_dependencies = initial_dependencies if is_simple_buy_without_selling: self.logger.info( f"Step 2/3: skipped: no coin to sell for " f"{self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market}" ) else: self.logger.info( f"Step 2/3: selling coins to free " f"{self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market}" ) orders += await self._sell_indexed_coins_for_reference_market(details, initial_dependencies) sell_orders_dependencies = signals.get_orders_dependencies(orders) # 3. split reference market into indexed coins self.logger.info( f"Step 3/3: buying coins using " f"{self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market}" ) orders += await self._split_reference_market_into_indexed_coins( details, is_simple_buy_without_selling, sell_orders_dependencies ) # reset flag to relog if a next rebalance is aborted self._already_logged_aborted_rebalance_error = False except (trading_errors.MissingMinimalExchangeTradeVolume, RebalanceAborted) as err: log_level = self.logger.warning if isinstance(err, RebalanceAborted) and not self._already_logged_aborted_rebalance_error: log_level = self.logger.error self._already_logged_aborted_rebalance_error = True log_level( f"Aborting rebalance on {self.exchange_manager.exchange_name}: {err} ({err.__class__.__name__})" ) self._update_producer_last_activity( IndexActivity.REBALANCING_SKIPPED, RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) finally: self.logger.info("Portoflio rebalance process complete") return orders async def _sell_indexed_coins_for_reference_market( self, details: dict, dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> list: removed_coins_to_sell_orders = [] if removed_coins_to_sell := list(details[RebalanceDetails.REMOVE.value]): removed_coins_to_sell_orders = await trading_modes.convert_assets_to_target_asset( self.trading_mode, removed_coins_to_sell, self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {}, dependencies=dependencies ) if ( details[RebalanceDetails.REMOVE.value] and not ( details[RebalanceDetails.BUY_MORE.value] or details[RebalanceDetails.ADD.value] or details[RebalanceDetails.SWAP.value] ) ): # if rebalance is triggered by removed assets, make sure that the asset can actually be sold # otherwise the whole rebalance is useless sold_coins = [ symbol_util.parse_symbol(order.symbol).base if order.side is trading_enums.TradeOrderSide.SELL else symbol_util.parse_symbol(order.symbol).quote for order in removed_coins_to_sell_orders ] if not any( asset in sold_coins for asset in details[RebalanceDetails.REMOVE.value] ): self.logger.info( f"Cancelling rebalance: not enough {list(details[RebalanceDetails.REMOVE.value])} funds to sell" ) raise trading_errors.MissingMinimalExchangeTradeVolume( f"not enough {list(details[RebalanceDetails.REMOVE.value])} funds to sell" ) order_coins_to_sell = self._get_coins_to_sell(details) orders = await trading_modes.convert_assets_to_target_asset( self.trading_mode, order_coins_to_sell, self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {}, dependencies=dependencies ) + removed_coins_to_sell_orders if orders: # ensure orders are filled await asyncio.gather( *[ trading_personal_data.wait_for_order_fill( order, self.FILL_ORDER_TIMEOUT, True ) for order in orders ] ) return orders def _get_coins_to_sell(self, details: dict) -> list: return list(details[RebalanceDetails.SWAP.value]) or ( self.trading_mode.indexed_coins ) def _can_simply_buy_coins_without_selling(self, details: dict) -> bool: simple_buy_coins = self._get_simple_buy_coins(details) if not simple_buy_coins: return False # check if there is enough free funds to buy those coins ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market reference_market_to_split = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_traded_assets_holdings_value(ref_market, None) free_reference_market_holding = \ self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( ref_market ).available cumulated_ratio = sum( self.trading_mode.get_target_ratio(coin) for coin in simple_buy_coins ) tolerated_min_amount = reference_market_to_split * cumulated_ratio * self.SIMPLE_ADD_MIN_TOLERANCE_RATIO # can reach target ratios without selling if this condition is met return tolerated_min_amount <= free_reference_market_holding def _get_simple_buy_coins(self, details: dict) -> list: # Returns the list of coins to simply buy. # Used to avoid a full rebalance when coins are seen as added to a basket # AND funds are available to buy it AND no asset should be sold added = details[RebalanceDetails.ADD.value] or details[RebalanceDetails.BUY_MORE.value] if added and not ( details[RebalanceDetails.SWAP.value] or details[RebalanceDetails.SELL_SOME.value] or details[RebalanceDetails.REMOVE.value] or details[RebalanceDetails.FORCED_REBALANCE.value] ): added_coins = list(details[RebalanceDetails.ADD.value]) + list(details[RebalanceDetails.BUY_MORE.value]) return [ coin for coin in self.trading_mode.indexed_coins # iterate over self.trading_mode.indexed_coins to keep order if coin in added_coins ] + [ coin for coin in added_coins if coin not in self.trading_mode.indexed_coins ] return [] async def _ensure_enough_funds_to_buy_after_selling(self): reference_market_to_split = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_traded_assets_holdings_value( self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, None ) # will raise if funds are missing await self._get_symbols_and_amounts(self.trading_mode.indexed_coins, reference_market_to_split) async def _split_reference_market_into_indexed_coins( self, details: dict, is_simple_buy_without_selling: bool, dependencies: typing.Optional[commons_signals.SignalDependencies] ): orders = [] ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market if details[RebalanceDetails.SWAP.value] or is_simple_buy_without_selling: # has to infer total reference market holdings reference_market_to_split = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_traded_assets_holdings_value(ref_market, None) coins_to_buy = ( self._get_simple_buy_coins(details) if is_simple_buy_without_selling else list(details[RebalanceDetails.SWAP.value].values()) ) else: # can use actual reference market holdings: everything has been sold reference_market_to_split = \ self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( ref_market ).available coins_to_buy = self.trading_mode.indexed_coins self.logger.info(f"Splitting {reference_market_to_split} {ref_market} to buy {coins_to_buy}") amount_by_symbol = await self._get_symbols_and_amounts(coins_to_buy, reference_market_to_split) for symbol, ideal_amount in amount_by_symbol.items(): orders.extend(await self._buy_coin(symbol, ideal_amount, dependencies)) if not orders: raise trading_errors.MissingMinimalExchangeTradeVolume() return orders async def _get_symbols_and_amounts(self, coins_to_buy, reference_market_to_split): amount_by_symbol = {} for coin in coins_to_buy: if coin == self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market: # nothing to do for reference market, keep as is continue symbol = symbol_util.merge_currencies( coin, self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market ) price = await trading_personal_data.get_up_to_date_price( self.exchange_manager, symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT ) symbol_market = self.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) ratio = self.trading_mode.get_target_ratio(coin) if ratio == trading_constants.ZERO: # coin is not to handle continue try: ideal_amount = ratio * reference_market_to_split / price except decimal.DecimalException as err: raise RebalanceAborted( f"Error computing {symbol} ideal amount ({ratio=}, {reference_market_to_split=}, {price=}): {err=}" ) from err # worse case (ex with 5 USDT min order size): exactly 5 USDT can be in portfolio, we therefore want to # trade at lease 5 USDT to be able to buy more. # - we want ideal_amount - min_cost > min_cost # - in other words ideal_amount > 2*min_cost => ideal_amount/2 > min cost adapted_quantity = trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( ideal_amount / decimal.Decimal(2), price, symbol_market ) if not adapted_quantity: # if we can't create an order in this case, we won't be able to balance the portfolio. # don't try to avoid triggering new rebalances on each wakeup cycling market sell & buy orders raise trading_errors.MissingMinimalExchangeTradeVolume( f"Can't buy {symbol}: available funds are too low to buy {ratio*trading_constants.ONE_HUNDRED}% " f"of {reference_market_to_split} holdings: {round(ideal_amount / decimal.Decimal(2), 9)} {coin} " f"required order size is not compatible with {symbol} exchange requirements: " f"{symbol_market[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS.value]}." ) amount_by_symbol[symbol] = ideal_amount return amount_by_symbol async def _buy_coin(self, symbol, ideal_amount, dependencies: typing.Optional[commons_signals.SignalDependencies]) -> list: current_symbol_holding, current_market_holding, market_quantity, price, symbol_market = \ await trading_personal_data.get_pre_order_data( self.exchange_manager, symbol=symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT ) order_target_price = price # ideally use the expected reference_market_available_holdings ratio, fallback to available # holdings if necessary target_quantity = min(ideal_amount, current_market_holding / order_target_price) ideal_quantity = target_quantity - current_symbol_holding if ideal_quantity <= trading_constants.ZERO: return [] quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, symbol, trading_enums.TraderOrderType.BUY_MARKET, ideal_quantity, order_target_price, trading_enums.TradeOrderSide.BUY ) created_orders = [] orders_should_have_been_created = False ideal_order_type = trading_enums.TraderOrderType.BUY_MARKET order_type = ( ideal_order_type if self.exchange_manager.exchange.is_market_open_for_order_type(symbol, ideal_order_type) else trading_enums.TraderOrderType.BUY_LIMIT ) if trading_personal_data.get_trade_order_type(order_type) is not trading_enums.TradeOrderType.MARKET: # can't use market orders: use limit orders with price a bit above the current price to instant fill it. order_target_price, quantity = \ trading_modes.get_instantly_filled_limit_order_adapted_price_and_quantity( order_target_price, quantity, order_type ) for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, order_target_price, symbol_market ): orders_should_have_been_created = True current_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=symbol, current_price=price, quantity=order_quantity, price=order_price, ) created_order = await self.trading_mode.create_order(current_order, dependencies=dependencies) created_orders.append(created_order) if created_orders: return created_orders if orders_should_have_been_created: raise trading_errors.OrderCreationError() raise trading_errors.MissingMinimalExchangeTradeVolume() class IndexTradingModeProducer(trading_modes.AbstractTradingModeProducer): REFRESH_INTERVAL = "refresh_interval" CANCEL_OPEN_ORDERS = "cancel_open_orders" REBALANCE_TRIGGER_MIN_PERCENT = "rebalance_trigger_min_percent" SELECTED_REBALANCE_TRIGGER_PROFILE = "selected_rebalance_trigger_profile" REBALANCE_TRIGGER_PROFILES = "rebalance_trigger_profiles" REBALANCE_TRIGGER_PROFILE_NAME = "name" REBALANCE_TRIGGER_PROFILE_MIN_PERCENT = "min_percent" QUOTE_ASSET_REBALANCE_TRIGGER_MIN_PERCENT = "quote_asset_rebalance_trigger_min_percent" SYNCHRONIZATION_POLICY = "synchronization_policy" SELL_UNINDEXED_TRADED_COINS = "sell_unindexed_traded_coins" INDEX_CONTENT = "index_content" MIN_INDEXED_COINS = 1 ALLOWED_1_TO_1_SWAP_COUNTS = 1 MIN_RATIO_TO_SELL = decimal.Decimal("0.0001") # 1/10000 QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD = decimal.Decimal("0.1") # 10% def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) self._last_trigger_time = 0 self.state = trading_enums.EvaluatorStates.NEUTRAL async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() await super().stop() async def ohlcv_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame: str, candle: dict, init_call: bool = False): await self._check_index_if_necessary() async def kline_callback(self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, time_frame, kline: dict): await self._check_index_if_necessary() async def _check_index_if_necessary(self): current_time = self.exchange_manager.exchange.get_exchange_current_time() if ( current_time - self._last_trigger_time ) >= self.trading_mode.refresh_interval_days * commons_constants.DAYS_TO_SECONDS: if self.trading_mode.automatically_update_historical_config_on_set_intervals(): self.trading_mode.update_config_and_user_inputs_if_necessary() if self.trading_mode.is_processing_rebalance: self.logger.info( f"[{self.exchange_manager.exchange_name}] Index is already being rebalanced, skipping index check" ) return if len(self.trading_mode.indexed_coins) < self.MIN_INDEXED_COINS: self.logger.error( f"At least {self.MIN_INDEXED_COINS} coin is required to maintain an index. Please " f"select more trading pairs using " f"{self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market} as " f"quote currency." ) else: self._notify_if_missing_too_many_coins() await self.ensure_index() if not self.trading_mode.is_updating_at_each_price_change(): self.logger.debug(f"Next index check in {self.trading_mode.refresh_interval_days} days") self._last_trigger_time = current_time async def ensure_index(self): await self._wait_for_symbol_prices_and_profitability_init(self._get_config_init_timeout()) self.logger.info( f"Ensuring Index on [{self.exchange_manager.exchange_name}] " f"{len(self.trading_mode.indexed_coins)} coins: {self.trading_mode.indexed_coins} with reference market: " f"{self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market}" ) dependencies = None if self.trading_mode.cancel_open_orders: dependencies = await self.cancel_traded_pairs_open_orders_if_any() if self.trading_mode.requires_initializing_appropriate_coins_distribution: self.trading_mode.ensure_updated_coins_distribution(adapt_to_holdings=True) self.trading_mode.requires_initializing_appropriate_coins_distribution = False is_rebalance_required, rebalance_details = self._get_rebalance_details() if is_rebalance_required: await self._trigger_rebalance(rebalance_details, dependencies) self.last_activity = trading_modes.TradingModeActivity( IndexActivity.REBALANCING_DONE, rebalance_details, ) else: allowance = round(self.trading_mode.rebalance_trigger_min_ratio * trading_constants.ONE_HUNDRED, 2) self.logger.info( f"[{self.exchange_manager.exchange_name}] is following the index [+/-{allowance}%], no rebalance is required." ) self.last_activity = trading_modes.TradingModeActivity(IndexActivity.REBALANCING_SKIPPED) async def _trigger_rebalance(self, rebalance_details: dict, dependencies: typing.Optional[commons_signals.SignalDependencies]): self.logger.info( f"Triggering rebalance on [{self.exchange_manager.exchange_name}] " f"with rebalance details: {rebalance_details}." ) await self.submit_trading_evaluation( cryptocurrency=None, symbol=None, # never set symbol in order to skip consumer.can_create_order check time_frame=None, final_note=None, state=trading_enums.EvaluatorStates.NEUTRAL, data=rebalance_details, dependencies=dependencies ) # send_notification await self._send_alert_notification() async def _send_alert_notification(self): if self.exchange_manager.is_backtesting: return try: import octobot_services.api as services_api import octobot_services.enums as services_enum title = "Index trigger" alert = f"Rebalance triggered for {len(self.trading_mode.indexed_coins)} coins" await services_api.send_notification(services_api.create_notification( alert, title=title, markdown_text=alert, category=services_enum.NotificationCategory.PRICE_ALERTS )) except ImportError as e: self.logger.exception(e, True, f"Impossible to send notification: {e}") def _notify_if_missing_too_many_coins(self): if ideal_distribution := self.trading_mode.get_ideal_distribution(self.trading_mode.trading_config): if len(self.trading_mode.indexed_coins) < len(ideal_distribution) / 2: self.logger.error( f"Less than half of configured coins can be traded on {self.exchange_manager.exchange_name}. " f"Traded: {self.trading_mode.indexed_coins}, configured: {ideal_distribution}" ) def _register_coins_update(self, rebalance_details: dict) -> bool: should_rebalance = False for coin in set(self.trading_mode.indexed_coins): target_ratio = self.trading_mode.get_target_ratio(coin) coin_ratio = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio( coin, traded_symbols_only=True ) beyond_ratio = True if coin_ratio == trading_constants.ZERO and target_ratio > trading_constants.ZERO: # missing coin in portfolio rebalance_details[RebalanceDetails.ADD.value][coin] = target_ratio should_rebalance = True elif coin_ratio < target_ratio - self.trading_mode.rebalance_trigger_min_ratio: # not enough in portfolio rebalance_details[RebalanceDetails.BUY_MORE.value][coin] = target_ratio should_rebalance = True elif coin_ratio > target_ratio + self.trading_mode.rebalance_trigger_min_ratio: # too much in portfolio rebalance_details[RebalanceDetails.SELL_SOME.value][coin] = target_ratio should_rebalance = True else: beyond_ratio = False if beyond_ratio: allowance = round(self.trading_mode.rebalance_trigger_min_ratio * trading_constants.ONE_HUNDRED, 2) self.logger.info( f"{coin} is beyond the target ratio of {round(target_ratio * trading_constants.ONE_HUNDRED, 2)}[+/-{allowance}]%, " f"ratio: {round(coin_ratio * trading_constants.ONE_HUNDRED, 2)}%. A rebalance is required." ) return should_rebalance def _register_removed_coin(self, rebalance_details: dict, available_traded_bases: set[str]) -> bool: should_rebalance = False for coin in self.trading_mode.get_removed_coins_from_config(available_traded_bases): if coin in available_traded_bases: coin_ratio = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio( coin, traded_symbols_only=True ) if coin_ratio >= self.MIN_RATIO_TO_SELL: # coin to sell in portfolio rebalance_details[RebalanceDetails.REMOVE.value][coin] = coin_ratio self.logger.info( f"{coin} (holdings: {round(coin_ratio * trading_constants.ONE_HUNDRED, 3)}%) is not in index " f"anymore. A rebalance is required." ) should_rebalance = True else: if trading_util.is_symbol_disabled(self.exchange_manager.config, coin): self.logger.info( f"Ignoring {coin} holding: {coin} is not in index anymore but is disabled." ) else: self.logger.error( f"Ignoring {coin} holding: Can't sell {coin} as it is not in any trading pair" f" but is not in index anymore. This is unexpected" ) return should_rebalance def _register_quote_asset_rebalance(self, rebalance_details: dict) -> bool: non_indexed_quote_assets_ratio = self._get_non_indexed_quote_assets_ratio() if self._should_rebalance_due_to_non_indexed_quote_assets_ratio( non_indexed_quote_assets_ratio, rebalance_details ): rebalance_details[RebalanceDetails.FORCED_REBALANCE.value] = True self.logger.info( f"Rebalancing due to a high non-indexed quote asset holdings ratio: " f"{round(non_indexed_quote_assets_ratio * trading_constants.ONE_HUNDRED, 2)}%, quote rebalance " f"threshold = {self.trading_mode.quote_asset_rebalance_ratio_threshold * trading_constants.ONE_HUNDRED}%" ) return True return False def _empty_rebalance_details(self) -> dict: return { RebalanceDetails.SELL_SOME.value: {}, RebalanceDetails.BUY_MORE.value: {}, RebalanceDetails.REMOVE.value: {}, RebalanceDetails.ADD.value: {}, RebalanceDetails.SWAP.value: {}, RebalanceDetails.FORCED_REBALANCE.value: False, } def _get_rebalance_details(self) -> (bool, dict): rebalance_details = self._empty_rebalance_details() should_rebalance = False # look for coins update in indexed_coins available_traded_bases = set( symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols ) # compute rebalance details for current coins distribution if self.trading_mode.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE: should_rebalance = self._register_removed_coin(rebalance_details, available_traded_bases) should_rebalance = self._register_coins_update(rebalance_details) or should_rebalance should_rebalance = self._register_quote_asset_rebalance(rebalance_details) or should_rebalance if ( should_rebalance and self.trading_mode.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE ): # use latest coins distribution to compute rebalance details self.trading_mode.ensure_updated_coins_distribution(force_latest=True) # re-compute the whole rebalance details for latest coins distribution # to avoid side effects from previous distribution rebalance_details = self._empty_rebalance_details() self._register_removed_coin(rebalance_details, available_traded_bases) self._register_coins_update(rebalance_details) self._register_quote_asset_rebalance(rebalance_details) if not rebalance_details[RebalanceDetails.FORCED_REBALANCE.value]: # finally, compute swaps when no forced rebalance is required self._resolve_swaps(rebalance_details) for origin, target in rebalance_details[RebalanceDetails.SWAP.value].items(): origin_ratio = round( rebalance_details[RebalanceDetails.REMOVE.value][origin] * trading_constants.ONE_HUNDRED, 3 ) target_ratio = round( rebalance_details[RebalanceDetails.ADD.value].get( target, rebalance_details[RebalanceDetails.BUY_MORE.value].get(target, trading_constants.ZERO) ) * trading_constants.ONE_HUNDRED, 3 ) or "???" self.logger.info( f"Swapping {origin} (holding ratio: {origin_ratio}%) for {target} (to buy ratio: {target_ratio}%) " f"on [{self.exchange_manager.exchange_name}]: ratios are similar enough to allow swapping." ) return (should_rebalance or rebalance_details[RebalanceDetails.FORCED_REBALANCE.value]), rebalance_details def _should_rebalance_due_to_non_indexed_quote_assets_ratio(self, non_indexed_quote_assets_ratio: decimal.Decimal, rebalance_details: dict) -> bool: total_added_ratio = ( self._sum_ratios(rebalance_details, RebalanceDetails.ADD.value) + self._sum_ratios(rebalance_details, RebalanceDetails.BUY_MORE.value) ) if ( total_added_ratio * (trading_constants.ONE - self.QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD) <= non_indexed_quote_assets_ratio <= total_added_ratio * (trading_constants.ONE + self.QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD) ): total_removed_ratio = ( self._sum_ratios(rebalance_details, RebalanceDetails.REMOVE.value) + self._sum_ratios(rebalance_details, RebalanceDetails.SELL_SOME.value) ) # added coins are equivalent to free quote assets: just buy with quote assets if total_removed_ratio == trading_constants.ZERO: return False # there are removed coins or added ratio is not equivalent to free quote assets: rebalance if necessary min_ratio = min( min( self.trading_mode.get_target_ratio(coin) for coin in self.trading_mode.indexed_coins ) if self.trading_mode.indexed_coins else self.trading_mode.quote_asset_rebalance_ratio_threshold, self.trading_mode.quote_asset_rebalance_ratio_threshold ) return non_indexed_quote_assets_ratio >= min_ratio @staticmethod def _sum_ratios(rebalance_details: dict, key: str) -> decimal.Decimal: return decimal.Decimal(str(sum( ratio for ratio in rebalance_details[key].values() ))) if rebalance_details[key] else trading_constants.ZERO def _get_non_indexed_quote_assets_ratio(self) -> decimal.Decimal: return decimal.Decimal(str(sum( self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio( quote, traded_symbols_only=True ) for quote in set( symbol.quote for symbol in self.exchange_manager.exchange_config.traded_symbols if symbol.quote not in self.trading_mode.indexed_coins ) ))) def _resolve_swaps(self, details: dict): removed = details[RebalanceDetails.REMOVE.value] details[RebalanceDetails.SWAP.value] = {} if details[RebalanceDetails.SELL_SOME.value]: # rebalance within held coins: global rebalance required return added = {**details[RebalanceDetails.ADD.value], **details[RebalanceDetails.BUY_MORE.value]} if len(removed) == len(added) == self.ALLOWED_1_TO_1_SWAP_COUNTS: for removed_coin, removed_ratio, added_coin, added_ratio in zip( removed, removed.values(), added, added.values() ): added_holding_ratio = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio( added_coin, traded_symbols_only=True, coins_whitelist=self.trading_mode.get_coins_to_consider_for_ratio() ) required_added_ratio = added_ratio - added_holding_ratio if ( removed_ratio - self.trading_mode.rebalance_trigger_min_ratio < required_added_ratio < removed_ratio + self.trading_mode.rebalance_trigger_min_ratio ): # removed can be swapped for added: only sell removed details[RebalanceDetails.SWAP.value][removed_coin] = added_coin else: # reset to_sell to sell everything details[RebalanceDetails.SWAP.value] = {} return def get_channels_registration(self): # use candles to trigger at each candle interval and when initializing topics = [ self.TOPIC_TO_CHANNEL_NAME[commons_enums.ActivationTopics.FULL_CANDLES.value], ] if self.trading_mode.is_updating_at_each_price_change(): # use kline to trigger at each price change self.logger.info(f"Using price change bound update instead of time-based update.") topics.append( self.TOPIC_TO_CHANNEL_NAME[commons_enums.ActivationTopics.IN_CONSTRUCTION_CANDLES.value] ) return topics async def cancel_traded_pairs_open_orders_if_any(self) -> typing.Optional[commons_signals.SignalDependencies]: dependencies = commons_signals.SignalDependencies() if symbol_open_orders := [ order for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders() if order.symbol in self.exchange_manager.exchange_config.traded_symbol_pairs and not isinstance(order, trading_personal_data.MarketOrder) # market orders can't be cancelled ]: self.logger.info( f"Cancelling {len(symbol_open_orders)} open orders" ) for order in symbol_open_orders: try: is_cancelled, dependency = await self.trading_mode.cancel_order(order) if is_cancelled: dependencies.extend(dependency) except trading_errors.UnexpectedExchangeSideOrderStateError as err: self.logger.warning(f"Skipped order cancel: {err}, order: {order}") return dependencies or None class IndexTradingMode(trading_modes.AbstractTradingMode): MODE_PRODUCER_CLASSES = [IndexTradingModeProducer] MODE_CONSUMER_CLASSES = [IndexTradingModeConsumer] SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION = True SUPPORTS_HEALTH_CHECK = False def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.refresh_interval_days = 1 self.rebalance_trigger_min_ratio = decimal.Decimal(float(DEFAULT_REBALANCE_TRIGGER_MIN_RATIO)) self.rebalance_trigger_profiles: typing.Optional[list] = None self.selected_rebalance_trigger_profile: typing.Optional[dict] = None self.ratio_per_asset = {} self.sell_unindexed_traded_coins = True self.cancel_open_orders = True self.total_ratio_per_asset = trading_constants.ZERO self.quote_asset_rebalance_ratio_threshold = decimal.Decimal(str(DEFAULT_QUOTE_ASSET_REBALANCE_TRIGGER_MIN_RATIO)) self.synchronization_policy: SynchronizationPolicy = SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE self.requires_initializing_appropriate_coins_distribution = False self.indexed_coins = [] self.is_processing_rebalance = False def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ trading_config = self.trading_config self.refresh_interval_days = float(self.UI.user_input( IndexTradingModeProducer.REFRESH_INTERVAL, commons_enums.UserInputTypes.FLOAT, self.refresh_interval_days, inputs, min_val=0, title="Trigger period: Days to wait between each rebalance. Can be a fraction of a day. " "When set to 0, every new price will trigger a rebalance check.", )) self.quote_asset_rebalance_ratio_threshold = decimal.Decimal(str(self.UI.user_input( IndexTradingModeProducer.QUOTE_ASSET_REBALANCE_TRIGGER_MIN_PERCENT, commons_enums.UserInputTypes.FLOAT, float(self.quote_asset_rebalance_ratio_threshold * trading_constants.ONE_HUNDRED), inputs, min_val=0, max_val=100, title="Quote asset rebalance cap: maximum allowed percent holding of traded pairs' quote asset before " "triggering a rebalance. Useful to force a rebalance when adding quote asset to the portfolio", ))) / trading_constants.ONE_HUNDRED self.rebalance_trigger_min_ratio = decimal.Decimal(str(self.UI.user_input( IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT, commons_enums.UserInputTypes.FLOAT, float(self.rebalance_trigger_min_ratio * trading_constants.ONE_HUNDRED), inputs, min_val=0, max_val=100, title="Rebalance cap: maximum allowed percent holding of a coin beyond initial ratios before " "triggering a rebalance.", ))) / trading_constants.ONE_HUNDRED self.rebalance_trigger_profiles = self.trading_config.get(IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, None) if self.rebalance_trigger_profiles: # only display selector if there are profiles to display rebalance_trigger_profiles_inputs = [{ IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: self.UI.user_input( IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME, commons_enums.UserInputTypes.TEXT, "profile name", inputs, parent_input_name=IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, array_indexes=[0], title=f"Name: name of the reference trigger profile" ), IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: self.UI.user_input( IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT, commons_enums.UserInputTypes.FLOAT, float(self.rebalance_trigger_min_ratio * trading_constants.ONE_HUNDRED), inputs, parent_input_name=IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, array_indexes=[0], min_val=0, max_val=100, title=( "Rebalance cap: maximum allowed percent holding of a coin beyond initial ratios before " "triggering a rebalance when this profile is selected." ) ), }] self.UI.user_input( IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, commons_enums.UserInputTypes.OBJECT_ARRAY, rebalance_trigger_profiles_inputs, inputs, other_schema_values={"minItems": 1, "uniqueItems": True}, item_title="Rebalance trigger profile", title="Rebalance trigger profiles", ) selected_rebalance_trigger_profile_name = self.UI.user_input( IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE, commons_enums.UserInputTypes.OPTIONS, None, inputs, options=[p[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME] for p in self.rebalance_trigger_profiles], title="Selected rebalance trigger profile, override the default Rebalance cap value.", ) selected_profile = [ p for p in self.rebalance_trigger_profiles if p[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME] == selected_rebalance_trigger_profile_name ] if selected_profile: self.selected_rebalance_trigger_profile = selected_profile[0] # apply selected rebalance trigger profile ratio self.rebalance_trigger_min_ratio = decimal.Decimal(str( self.selected_rebalance_trigger_profile[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT]) ) / trading_constants.ONE_HUNDRED else: self.logger.warning( f"Selected rebalance trigger profile {selected_rebalance_trigger_profile_name} not found in rebalance trigger profiles: {self.rebalance_trigger_profiles}" ) self.selected_rebalance_trigger_profile = None sync_policy: str = self.UI.user_input( IndexTradingModeProducer.SYNCHRONIZATION_POLICY, commons_enums.UserInputTypes.OPTIONS, self.synchronization_policy.value, inputs, options=[p.value for p in SynchronizationPolicy], editor_options={"enum_titles": [p.value.replace("_", " ") for p in SynchronizationPolicy]}, title="Synchronization policy: should coins that are removed from index be sold as soon as possible or only when rebalancing is triggered when coins don't follow the configured ratios.", ) try: self.synchronization_policy = SynchronizationPolicy(sync_policy) except ValueError as err: self.logger.exception( err, True, f"Impossible to parse synchronization policy: {err}. Using default policy: {self.synchronization_policy.value}." ) self.cancel_open_orders = float(self.UI.user_input( IndexTradingModeProducer.CANCEL_OPEN_ORDERS, commons_enums.UserInputTypes.BOOLEAN, self.cancel_open_orders, inputs, title="Cancel open orders: When enabled, open orders of the index trading pairs will be canceled to free " "funds and invest in the index content.", )) self.sell_unindexed_traded_coins = trading_config.get( IndexTradingModeProducer.SELL_UNINDEXED_TRADED_COINS, self.sell_unindexed_traded_coins ) if (not self.exchange_manager or not self.exchange_manager.is_backtesting) and \ authentication.Authenticator.instance().has_open_source_package(): self.UI.user_input(IndexTradingModeProducer.INDEX_CONTENT, commons_enums.UserInputTypes.OBJECT_ARRAY, trading_config.get(IndexTradingModeProducer.INDEX_CONTENT, None), inputs, item_title="Coin", other_schema_values={"minItems": 0, "uniqueItems": True}, title="Custom distribution: when used, only coins listed in this distribution and " "in your profile traded pairs will be traded. " "Leave empty to evenly allocate funds in each traded coin.") self.UI.user_input(index_distribution.DISTRIBUTION_NAME, commons_enums.UserInputTypes.TEXT, "BTC", inputs, other_schema_values={"minLength": 1}, parent_input_name=IndexTradingModeProducer.INDEX_CONTENT, title="Name of the coin.") self.UI.user_input(index_distribution.DISTRIBUTION_VALUE, commons_enums.UserInputTypes.FLOAT, 50, inputs, min_val=0, parent_input_name=IndexTradingModeProducer.INDEX_CONTENT, title="Weight of the coin within this distribution.") self.requires_initializing_appropriate_coins_distribution = self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE self.ensure_updated_coins_distribution() @classmethod def get_tentacle_config_traded_symbols(cls, config: dict, reference_market: str) -> list: return [ symbol_util.merge_currencies(asset[index_distribution.DISTRIBUTION_NAME], reference_market) for asset in (cls.get_ideal_distribution(config) or []) ] def is_updating_at_each_price_change(self): return self.refresh_interval_days == 0 def automatically_update_historical_config_on_set_intervals(self) -> bool: return ( self.supports_historical_config() and self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE ) def ensure_updated_coins_distribution(self, adapt_to_holdings: bool = False, force_latest: bool = False): distribution = self._get_supported_distribution(adapt_to_holdings, force_latest) self.ratio_per_asset = { asset[index_distribution.DISTRIBUTION_NAME]: asset for asset in distribution } self.total_ratio_per_asset = decimal.Decimal(sum( asset[index_distribution.DISTRIBUTION_VALUE] for asset in self.ratio_per_asset.values() )) self.indexed_coins = self._get_filtered_traded_coins(self.ratio_per_asset) def _get_filtered_traded_coins(self, ratio_per_asset: dict): if self.exchange_manager: ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market coins = set( symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols if symbol.base in ratio_per_asset and symbol.quote == ref_market ) if ref_market in ratio_per_asset and coins: # there is at least 1 coin traded against ref market, can add ref market if necessary coins.add(ref_market) return sorted(list(coins)) return [] def get_coins_to_consider_for_ratio(self) -> list: return self.indexed_coins + [self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market] @classmethod def get_ideal_distribution(cls, config: dict): return config.get(IndexTradingModeProducer.INDEX_CONTENT, None) @staticmethod def get_default_historical_time_frame() -> typing.Optional[commons_enums.TimeFrames]: return commons_enums.TimeFrames.ONE_DAY @staticmethod def use_backtesting_accurate_price_update() -> bool: """ Return True if the trading mode is more accurate in backtesting when using a short price update time frame """ # a short price update time frame is not increasing accuracy for index trading mode return False @staticmethod def get_config_history_propagated_tentacles_config_keys() -> list[str]: """ Returns the list of config keys that should be propagated to historical configurations """ return [ # The selected rebalance trigger profile should be applied to all historical configs # to ensure the user selected profile is always used IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE, IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, IndexTradingModeProducer.SYNCHRONIZATION_POLICY, ] def _get_currently_applied_historical_config_according_to_holdings( self, config: dict, traded_bases: set[str] ) -> dict: # 1. check if latest config is the running one if self._is_index_config_applied(config, traded_bases): self.logger.info(f"Using {self.get_name()} latest config.") return config # 2. check if historical configs are available (iterating from most recent to oldest) historical_configs = self.get_historical_configs( 0, self.exchange_manager.exchange.get_exchange_current_time() ) if not historical_configs or ( # only 1 historical config which is the same as the latest config len(historical_configs) == 1 and ( self.get_ideal_distribution(historical_configs[0]) == self.get_ideal_distribution(config) and historical_configs[0][IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] == config[IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT] ) ): # current config is the first historical config self.logger.info(f"Using {self.get_name()} latest config as no historical configs are available.") return config for index, historical_config in enumerate(historical_configs): if self._is_index_config_applied(historical_config, traded_bases): self.logger.info(f"Using [N-{index}] {self.get_name()} historical config distribution: {self.get_ideal_distribution(historical_config)}.") return historical_config # 3. no suitable config found: return latest config self.logger.info(f"No suitable {self.get_name()} config found: using latest distribution: {self.get_ideal_distribution(config)}.") return config def _is_index_config_applied(self, config: dict, traded_bases: set[str]) -> bool: full_assets_distribution = self.get_ideal_distribution(config) if not full_assets_distribution: return False assets_distribution = [ asset for asset in full_assets_distribution if asset[index_distribution.DISTRIBUTION_NAME] in traded_bases ] if len(assets_distribution) != len(full_assets_distribution): # if assets are missing from traded pairs, the config is not applied # might be due to delisted or renamed coins missing_assets = [ asset[index_distribution.DISTRIBUTION_NAME] for asset in full_assets_distribution if asset not in assets_distribution ] self.logger.warning( f"Ignored {self.get_name()} config candidate as {len(missing_assets)} configured assets {missing_assets} are missing from {self.exchange_manager.exchange_name} traded pairs." ) return False total_ratio = decimal.Decimal(sum( asset[index_distribution.DISTRIBUTION_VALUE] for asset in assets_distribution )) if total_ratio == trading_constants.ZERO: return False min_trigger_ratio = self._get_config_min_ratio(config) for asset_distrib in assets_distribution: target_ratio = decimal.Decimal(str(asset_distrib[index_distribution.DISTRIBUTION_VALUE])) / total_ratio coin_ratio = self.exchange_manager.exchange_personal_data.portfolio_manager. \ portfolio_value_holder.get_holdings_ratio( asset_distrib[index_distribution.DISTRIBUTION_NAME], traded_symbols_only=True ) if not (target_ratio - min_trigger_ratio <= coin_ratio <= target_ratio + min_trigger_ratio): # not enough or too much in portfolio return False return True def _get_config_min_ratio(self, config: dict) -> decimal.Decimal: ratio = None rebalance_trigger_profiles = config.get(IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES, None) if rebalance_trigger_profiles: # 1. try to get ratio from selected rebalance trigger profile selected_rebalance_trigger_profile_name =config.get(IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE, None) selected_profile = [ p for p in rebalance_trigger_profiles if p[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME] == selected_rebalance_trigger_profile_name ] if selected_profile: selected_rebalance_trigger_profile = selected_profile[0] ratio = selected_rebalance_trigger_profile[IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT] if ratio is None: # 2. try to get ratio from direct config ratio = config.get(IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT) if ratio is None: # 3. default to current config ratio return self.rebalance_trigger_min_ratio return decimal.Decimal(str(ratio)) / trading_constants.ONE_HUNDRED def _get_supported_distribution(self, adapt_to_holdings: bool, force_latest: bool) -> list: if detailed_distribution := self.get_ideal_distribution(self.trading_config): traded_bases = set( symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols ) traded_bases.add(self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market) if ( (adapt_to_holdings or force_latest) and self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE ): if adapt_to_holdings: # when policy is SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE, the latest config might not be the # running one: confirm this using historical configs index_config = self._get_currently_applied_historical_config_according_to_holdings( self.trading_config, traded_bases ) else: # force latest available config try: index_config = self.get_historical_configs( 0, self.exchange_manager.exchange.get_exchange_current_time() )[0] self.logger.info(f"Updated {self.get_name()} to use latest distribution: {self.get_ideal_distribution(index_config)}.") except IndexError: index_config = self.trading_config detailed_distribution = self.get_ideal_distribution(index_config) if not detailed_distribution: raise ValueError(f"No distribution found in historical index config: {index_config}") distribution = [ asset for asset in detailed_distribution if asset[index_distribution.DISTRIBUTION_NAME] in traded_bases ] if removed_assets := [ asset[index_distribution.DISTRIBUTION_NAME] for asset in detailed_distribution if asset not in distribution ]: self.logger.info( f"Ignored {len(removed_assets)} assets {removed_assets} from configured " f"distribution as absent from traded pairs." ) return distribution else: # compute uniform distribution over traded assets return index_distribution.get_uniform_distribution([ symbol.base for symbol in self.exchange_manager.exchange_config.traded_symbols ]) if self.exchange_manager else [] def get_removed_coins_from_config(self, available_traded_bases) -> list: removed_coins = [] if self.get_ideal_distribution(self.trading_config) and self.sell_unindexed_traded_coins: # only remove non indexed coins if an ideal distribution is set removed_coins = [ coin for coin in available_traded_bases if coin not in self.indexed_coins and coin != self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market ] if self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE: # identify coins to sell from previous config if not (self.previous_trading_config and self.trading_config): return removed_coins current_coins = [ asset[index_distribution.DISTRIBUTION_NAME] for asset in (self.get_ideal_distribution(self.trading_config) or []) ] ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market return list(set(removed_coins + [ asset[index_distribution.DISTRIBUTION_NAME] for asset in self.previous_trading_config[IndexTradingModeProducer.INDEX_CONTENT] if asset[index_distribution.DISTRIBUTION_NAME] not in current_coins and ( asset[index_distribution.DISTRIBUTION_NAME] != self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market ) ])) elif self.synchronization_policy == SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE: # identify coins to sell from historical configs historical_configs = self.get_historical_configs( # use 0 a the initial config time as only relevant historical configs should be available 0, self.exchange_manager.exchange.get_exchange_current_time() ) if not (historical_configs and self.trading_config): return removed_coins current_coins = [ asset[index_distribution.DISTRIBUTION_NAME] for asset in (self.get_ideal_distribution(self.trading_config) or []) ] ref_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market removed_coins_from_historical_configs = set() for historical_config in historical_configs: for asset in historical_config[IndexTradingModeProducer.INDEX_CONTENT]: asset_name = asset[index_distribution.DISTRIBUTION_NAME] if asset_name not in current_coins and asset_name != ref_market: removed_coins_from_historical_configs.add(asset_name) return list(removed_coins_from_historical_configs.union(removed_coins)) else: self.logger.error(f"Unknown synchronization policy: {self.synchronization_policy}") return [] def get_target_ratio(self, currency) -> decimal.Decimal: if currency in self.ratio_per_asset: try: return ( decimal.Decimal(str( self.ratio_per_asset[currency][index_distribution.DISTRIBUTION_VALUE] )) / self.total_ratio_per_asset ) except (decimal.DivisionByZero, decimal.InvalidOperation): pass return trading_constants.ZERO @classmethod def get_is_symbol_wildcard(cls) -> bool: return True @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, ] def get_current_state(self) -> tuple: return trading_enums.EvaluatorStates.NEUTRAL.name, f"Indexing {len(self.indexed_coins)} coins" async def single_exchange_process_optimize_initial_portfolio( self, sellable_assets: list, target_asset: str, tickers: dict ) -> list: return await trading_modes.convert_assets_to_target_asset( self, sellable_assets, target_asset, tickers ) ================================================ FILE: Trading/Mode/index_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["IndexTradingMode"], "tentacles-requirements": [] } ================================================ FILE: Trading/Mode/index_trading_mode/resources/IndexTradingMode.md ================================================ The Index trading mode splits and maintains your portfolio distributed between the traded currencies. It enables to maintain a crypto index based on your choice of coins. To know more, checkout the full Index trading mode guide. ### Content of the Index The Index is defined by the selected traded pairs against your reference market in the profile configuration section. Example: - Your reference market is USDT - Your traded pairs are BTC/USDT, ETH/USDT, SOL/USDT, ADA/USDT Then your index will be made of 25% BTC, 25% ETH, 25% SOL and 25% ADA. Each coin's holding % will be computed against USDT and checked on a regular basis. You can also specify a specific % for each coin using a Custom distribution using the [Premium OctoBot extension](extensions). When starting the Index trading mode with a new configuration, or if your current portfolio doesn't reflect the target of the index, your portfolio will automatically be adapted to reproduce the index at the best accuracy possible. ### Index rebalance An Index rebalance is the event when OctoBot is sending orders to the exchange to adapt the content of your portfolio in order to reproduce the configuration of your Index. Once your Index trading mode has started, OctoBot will maintain the index content by automatically checking the content of your portfolio of a regular basis and will trigger a rebalance if necessary. Your portfolio content is checked every configured `Trigger period` days. Decimal values can be used to check multiple times a day. If during an index check, your OctoBot detects that your portfolio content doesn't comply with your index configuration, it will trigger a rebalance. If `Trigger period` is set to `0`, then each new price of any indexed coin will trigger an index checkup and a rebalance if conditions are met. ### Rebalance cap When checking for rebalance, the Index trading mode also uses your `Rebalance cap` configuration before considering your portfolio out of synch with your index configuration. The Rebalance cap is an allowed percent of allocation that will avoid triggering a rebalance as long as any coin holding is still within the ideal holding % plus or minus the rebalance cap. Example: An index on 3 coins with a 33.33% target on each coin and a Rebalance cap of 5% will trigger a rebalance if the holding if any of those 3 coins takes more than 38.33% or less than 28.33% of the portfolio Warning on high Rebalance caps: When your index Rebalance cap is higher or equal to the target holding % of a coin, no rebalance will be triggered if your holdings of this coin become very low, rebalances will only be triggered when holdings are getting too high. This is a special case that can happen when using a large Rebalance cap. Example: Let's take an index on 10 coins using a 10% target for each coin. Using a Rebalance cap of 11% will only trigger a rebalance if any of those 10 coins take more than 21% of the portfolio (10% + 11%). The other side: 10% - 11% = -1% is negative and therefore can't happen, which means rebalances won't be triggered from lower holdings in this configuration. Using a 9% rebalance cap however would trigger a rebalance at 1% holdings (10% - 9%). Please note that if the % held of a coin is 0%, then a rebalance will always trigger, ignoring Rebalance cap. ### Minimum funds To use the Index Trading Mode, the minimum required funds are twice the minimum exchange order amount for every traded coin. This means that when trading 3 coins on Binance, at least 3 times $5 x2, which is $30 is required. Please note that this is the bare minimum, it's better to have at least twice this amount. If the minimum is reached, the Index Trading Mode will stop updating its portfolio according to the index until the value of the portfolio raises back above the required minimum. ### OctoBot cloud indexes The [Premium OctoBot extension](extensions) enables your open source OctoBot to use and customize OctoBot cloud's automatically configured indexes. ================================================ FILE: Trading/Mode/index_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/index_trading_mode/tests/test_index_distribution.py ================================================ import decimal import pytest import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution def test_get_uniform_distribution(): assert index_distribution.get_uniform_distribution([]) == [] assert index_distribution.get_uniform_distribution(["BTC", "1", "2", "3"]) == [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 25, }, { index_distribution.DISTRIBUTION_NAME: "1", index_distribution.DISTRIBUTION_VALUE: 25, }, { index_distribution.DISTRIBUTION_NAME: "2", index_distribution.DISTRIBUTION_VALUE: 25, }, { index_distribution.DISTRIBUTION_NAME: "3", index_distribution.DISTRIBUTION_VALUE: 25, } ] assert index_distribution.get_uniform_distribution(["BTC", "1", "2"]) == [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 33.3, }, { index_distribution.DISTRIBUTION_NAME: "1", index_distribution.DISTRIBUTION_VALUE: 33.3, }, { index_distribution.DISTRIBUTION_NAME: "2", index_distribution.DISTRIBUTION_VALUE: 33.3, }, ] def test_get_linear_distribution(): with pytest.raises(ValueError): index_distribution.get_linear_distribution({}) assert index_distribution.get_linear_distribution({ "BTC": decimal.Decimal(122), "1": decimal.Decimal(12), "2": decimal.Decimal("0.4"), "3": decimal.Decimal(44) }) == [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 68.4, }, { index_distribution.DISTRIBUTION_NAME: "1", index_distribution.DISTRIBUTION_VALUE: 6.7, }, { index_distribution.DISTRIBUTION_NAME: "2", index_distribution.DISTRIBUTION_VALUE: 0.2, }, { index_distribution.DISTRIBUTION_NAME: "3", index_distribution.DISTRIBUTION_VALUE: 24.7, } ] assert index_distribution.get_linear_distribution({ "BTC": decimal.Decimal(12332), "1": decimal.Decimal(12), "3": decimal.Decimal(433334) }) == [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 2.8, }, { index_distribution.DISTRIBUTION_NAME: "1", index_distribution.DISTRIBUTION_VALUE: 0, }, { index_distribution.DISTRIBUTION_NAME: "3", index_distribution.DISTRIBUTION_VALUE: 97.2, }, ] def test_get_smoothed_distribution(): with pytest.raises(ValueError): index_distribution.get_smoothed_distribution({}) assert index_distribution.get_smoothed_distribution({ "BTC": decimal.Decimal(122), "1": decimal.Decimal(12), "2": decimal.Decimal("0.4"), "3": decimal.Decimal(44) }) == [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 43.1, }, { index_distribution.DISTRIBUTION_NAME: "1", index_distribution.DISTRIBUTION_VALUE: 19.9, }, { index_distribution.DISTRIBUTION_NAME: "2", index_distribution.DISTRIBUTION_VALUE: 6.4, }, { index_distribution.DISTRIBUTION_NAME: "3", index_distribution.DISTRIBUTION_VALUE: 30.7, } ] assert index_distribution.get_smoothed_distribution({ "BTC": decimal.Decimal(12332), "1": decimal.Decimal(12), "3": decimal.Decimal(433334) }) == [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 22.9, }, { index_distribution.DISTRIBUTION_NAME: "1", index_distribution.DISTRIBUTION_VALUE: 2.3, }, { index_distribution.DISTRIBUTION_NAME: "3", index_distribution.DISTRIBUTION_VALUE: 74.9, }, ] ================================================ FILE: Trading/Mode/index_trading_mode/tests/test_index_trading_mode.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import time import pytest import pytest_asyncio import os.path import mock import decimal import async_channel.util as channel_util import octobot_commons.enums as commons_enum import octobot_commons.tests.test_config as test_config import octobot_commons.constants as commons_constants import octobot_commons.symbols as commons_symbols import octobot_commons.configuration as commons_configuration import octobot_commons.signals as commons_signals import octobot_backtesting.api as backtesting_api import octobot_tentacles_manager.api as tentacles_manager_api import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchanges as exchanges import octobot_trading.personal_data as trading_personal_data import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import octobot_trading.modes import octobot_trading.errors as trading_errors import octobot_trading.signals as trading_signals import tentacles.Trading.Mode as Mode import tentacles.Trading.Mode.index_trading_mode.index_trading as index_trading import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import tests.test_utils.memory_check_util as memory_check_util import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def tools(): trader = None try: tentacles_manager_api.reload_tentacle_info() mode, trader = await _get_tools() yield mode, trader finally: if trader: await _stop(trader.exchange_manager) async def test_run_independent_backtestings_with_memory_check(): """ Should always be called first here to avoid other tests' related memory check issues """ tentacles_setup_config = tentacles_manager_api.create_tentacles_setup_config_with_tentacles( Mode.IndexTradingMode, ) config = test_config.load_test_config() config[commons_constants.CONFIG_TIME_FRAME] = [commons_enum.TimeFrames.FOUR_HOURS] _CONFIG = { Mode.IndexTradingMode.get_name(): { "required_strategies": [], "refresh_interval": 7, "rebalance_trigger_min_percent": 5, "index_content": [] }, } def config_proxy(tentacles_setup_config, klass): try: return _CONFIG[klass if isinstance(klass, str) else klass.get_name()] except KeyError: return {} with tentacles_manager_api.local_tentacle_config_proxy(config_proxy): with mock.patch.object(octobot_trading.modes.AbstractTradingMode, "get_historical_config", mock.Mock()) \ as get_historical_config: await memory_check_util.run_independent_backtestings_with_memory_check( config, tentacles_setup_config, use_multiple_asset_data_file=True ) # should not be called when no historical config is available (or it will log errors) get_historical_config.assert_not_called() def _get_config(tools, update): mode, trader = tools config = tentacles_manager_api.get_tentacle_config(trader.exchange_manager.tentacles_setup_config, mode.__class__) return {**config, **update} async def test_init_default_values(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) assert mode.refresh_interval_days == 1 assert mode.rebalance_trigger_min_ratio == decimal.Decimal(str(index_trading.DEFAULT_REBALANCE_TRIGGER_MIN_RATIO)) assert mode.quote_asset_rebalance_ratio_threshold == decimal.Decimal(str(index_trading.DEFAULT_QUOTE_ASSET_REBALANCE_TRIGGER_MIN_RATIO)) assert mode.ratio_per_asset == {'BTC': {'name': 'BTC', 'value': decimal.Decimal(100)}} assert mode.total_ratio_per_asset == decimal.Decimal(100) assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE assert mode.requires_initializing_appropriate_coins_distribution is False assert mode.indexed_coins == ["BTC"] assert mode.selected_rebalance_trigger_profile is None assert mode.rebalance_trigger_profiles is None async def test_init_config_values(tools): update = { index_trading.IndexTradingModeProducer.REFRESH_INTERVAL: 72, index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY: index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE.value, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 10.2, index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: None, index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.2, }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20.2, }, ], index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 53, }, { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 1, }, { index_distribution.DISTRIBUTION_NAME: "SOL", index_distribution.DISTRIBUTION_VALUE: 1, }, ] } # no selected rebalance trigger profile mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) assert mode.refresh_interval_days == 72 assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.102") assert mode.selected_rebalance_trigger_profile is None assert mode.rebalance_trigger_profiles == [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.2, }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20.2, }, ] assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE assert mode.requires_initializing_appropriate_coins_distribution is True assert mode.ratio_per_asset == { "BTC": { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 1, }, } assert mode.total_ratio_per_asset == decimal.Decimal("1") assert mode.indexed_coins == ["BTC"] # now with ETH as traded assets trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "ADA/USDT", "BTC/USDT"] ] mode.trading_config[index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE] = "profile-1" mode.init_user_inputs({}) assert mode.refresh_interval_days == 72 assert mode.rebalance_trigger_profiles == [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.2, }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20.2, }, ] assert mode.selected_rebalance_trigger_profile == { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.2, } # applied profile assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.052") assert mode.ratio_per_asset == { "ETH": { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 53, }, "BTC": { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 1, } # SOL is not added } assert mode.total_ratio_per_asset == decimal.Decimal("54") assert mode.indexed_coins == ["BTC", "ETH"] # sorted list # refresh user inputs trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "ADA/USDT", "BTC/USDT", "SOL/USDT"] ] mode.init_user_inputs({}) assert mode.refresh_interval_days == 72 assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.052") assert mode.ratio_per_asset == { "ETH": { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 53, }, "BTC": { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 1, }, "SOL": { index_distribution.DISTRIBUTION_NAME: "SOL", index_distribution.DISTRIBUTION_VALUE: 1, }, } assert mode.total_ratio_per_asset == decimal.Decimal("55") assert mode.indexed_coins == ["BTC", "ETH", "SOL"] # sorted list # add ref market in coin rations mode.trading_config["index_content"] = [ { index_distribution.DISTRIBUTION_NAME: "USDT", index_distribution.DISTRIBUTION_VALUE: 75, }, { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 25, }, ] # select profile 2 mode.trading_config[index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE] = "profile-2" mode.init_user_inputs({}) assert mode.refresh_interval_days == 72 assert mode.selected_rebalance_trigger_profile == { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20.2, } # applied profile assert mode.rebalance_trigger_min_ratio == decimal.Decimal("0.202") assert mode.ratio_per_asset == { "BTC": { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 25, }, "USDT": { index_distribution.DISTRIBUTION_NAME: "USDT", index_distribution.DISTRIBUTION_VALUE: 75, }, } assert mode.total_ratio_per_asset == decimal.Decimal("100") assert mode.indexed_coins == ["BTC", "USDT"] # sorted list # unknown profile mode.trading_config[index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE] = "unknown" mode.init_user_inputs({}) # back to non-profile config values bu profiles are loaded assert mode.rebalance_trigger_profiles == [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 5.2, }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 20.2, }, ] assert mode.selected_rebalance_trigger_profile is None assert mode.rebalance_trigger_min_ratio == decimal.Decimal(str(10.2 / 100)) # invalid synchronization policy mode.trading_config[index_trading.IndexTradingModeProducer.SYNCHRONIZATION_POLICY] = "invalid_policy" mode.init_user_inputs({}) # does no raise error # use current or default value assert mode.synchronization_policy == index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE async def test_single_exchange_process_optimize_initial_portfolio(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object( octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=["order_1"]) ) as convert_assets_to_target_asset_mock, mock.patch.object( mode, "cancel_order", mock.AsyncMock() ) as cancel_order_mock: # no open order orders = await mode.single_exchange_process_optimize_initial_portfolio(["BTC", "ETH"], "USDT", {}) convert_assets_to_target_asset_mock.assert_called_once_with(mode, ["BTC", "ETH"], "USDT", {}) cancel_order_mock.assert_not_called() assert orders == ["order_1"] convert_assets_to_target_asset_mock.reset_mock() # open orders of the given symbol are cancelled open_order_1 = trading_personal_data.SellLimitOrder(trader) open_order_2 = trading_personal_data.BuyLimitOrder(trader) open_order_3 = trading_personal_data.BuyLimitOrder(trader) open_order_1.update(order_type=trading_enums.TraderOrderType.SELL_LIMIT, order_id="open_order_1_id", symbol="BTC/USDT", current_price=decimal.Decimal("70"), quantity=decimal.Decimal("10"), price=decimal.Decimal("70")) open_order_2.update(order_type=trading_enums.TraderOrderType.BUY_LIMIT, order_id="open_order_2_id", symbol="ETH/USDT", current_price=decimal.Decimal("70"), quantity=decimal.Decimal("10"), price=decimal.Decimal("70"), reduce_only=True) open_order_3.update(order_type=trading_enums.TraderOrderType.BUY_LIMIT, order_id="open_order_2_id", symbol="ADA/USDT", current_price=decimal.Decimal("70"), quantity=decimal.Decimal("10"), price=decimal.Decimal("70"), reduce_only=True) await mode.exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(open_order_1) await mode.exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(open_order_2) await mode.exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(open_order_3) mode.exchange_manager.exchange_config.traded_symbol_pairs = ["BTC/USDT", "ETH/USDT"] orders = await mode.single_exchange_process_optimize_initial_portfolio(["BTC", "ETH"], "USDT", {}) convert_assets_to_target_asset_mock.assert_called_once_with(mode, ["BTC", "ETH"], "USDT", {}) cancel_order_mock.assert_not_called() assert orders == ["order_1"] convert_assets_to_target_asset_mock.reset_mock() async def test_get_target_ratio_with_config(tools): update = { "refresh_interval": 72, "rebalance_trigger_min_percent": 10.2, "index_content": [ { index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 1, }, { index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 53, }, ] } mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) assert mode.get_target_ratio("ETH") == decimal.Decimal('0') assert mode.get_target_ratio("BTC") == decimal.Decimal("1") # use 100% BTC as others are not in traded pairs assert mode.get_target_ratio("SOL") == decimal.Decimal("0") trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "ADA/USDT", "BTC/USDT", "SOL/USDT"] ] mode.init_user_inputs({}) assert mode.get_target_ratio("ETH") == decimal.Decimal('0.9814814814814814814814814815') assert mode.get_target_ratio("BTC") == decimal.Decimal("0.01851851851851851851851851852") assert mode.get_target_ratio("SOL") == decimal.Decimal("0") async def test_get_target_ratio_without_config(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) assert mode.get_target_ratio("ETH") == decimal.Decimal('0') assert mode.get_target_ratio("BTC") == decimal.Decimal("1") assert mode.get_target_ratio("SOL") == decimal.Decimal("0") trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "SOL/USDT", "BTC/USDT"] ] mode.ensure_updated_coins_distribution() assert mode.get_target_ratio("ETH") == decimal.Decimal('0.3333333333333333617834929233') assert mode.get_target_ratio("BTC") == decimal.Decimal("0.3333333333333333617834929233") assert mode.get_target_ratio("SOL") == decimal.Decimal("0.3333333333333333617834929233") assert mode.get_target_ratio("ADA") == decimal.Decimal("0") trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "BTC/USDT"] ] mode.ensure_updated_coins_distribution() assert mode.get_target_ratio("ETH") == decimal.Decimal('0.5') assert mode.get_target_ratio("BTC") == decimal.Decimal("0.5") assert mode.get_target_ratio("SOL") == decimal.Decimal("0") trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "BTC/USDT", "ADA/USDT", "SOL/USDT"] ] mode.ensure_updated_coins_distribution() assert mode.get_target_ratio("ETH") == decimal.Decimal('0.25') assert mode.get_target_ratio("BTC") == decimal.Decimal("0.25") assert mode.get_target_ratio("SOL") == decimal.Decimal("0.25") async def test_ohlcv_callback(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) current_time = time.time() with mock.patch.object(producer, "ensure_index", mock.AsyncMock()) as ensure_index_mock, \ mock.patch.object(producer, "_notify_if_missing_too_many_coins", mock.Mock()) \ as _notify_if_missing_too_many_coins_mock: with mock.patch.object( trader.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=current_time) ) as get_exchange_current_time_mock: # not enough indexed coins mode.indexed_coins = [] assert producer._last_trigger_time == 0 await producer.ohlcv_callback("binance", "123", "BTC", "BTC/USDT", None, None) ensure_index_mock.assert_not_called() _notify_if_missing_too_many_coins_mock.assert_not_called() assert get_exchange_current_time_mock.call_count == 1 # only called once as no historical config exists get_exchange_current_time_mock.reset_mock() assert producer._last_trigger_time == current_time # enough coins mode.indexed_coins = [1, 2, 3] # already called on this time await producer.ohlcv_callback("binance", "123", "BTC", "BTC/USDT", None, None) ensure_index_mock.assert_not_called() _notify_if_missing_too_many_coins_mock.assert_not_called() assert get_exchange_current_time_mock.call_count == 1 assert producer._last_trigger_time == current_time with mock.patch.object( trader.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=current_time * 2) ) as get_exchange_current_time_mock: mode.indexed_coins = [1, 2, 3] await producer.ohlcv_callback("binance", "123", "BTC", "BTC/USDT", None, None) ensure_index_mock.assert_called_once() _notify_if_missing_too_many_coins_mock.assert_called_once() assert get_exchange_current_time_mock.call_count == 1 assert producer._last_trigger_time == current_time * 2 async def test_notify_if_missing_too_many_coins(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object(producer.logger, "error", mock.Mock()) as error_mock: mode.trading_config[producer.INDEX_CONTENT] = [1, 2, 3, 4, 5] mode.indexed_coins = [1, 2, 3, 4, 5] producer._notify_if_missing_too_many_coins() error_mock.assert_not_called() mode.indexed_coins = [1, 2, 3] producer._notify_if_missing_too_many_coins() error_mock.assert_not_called() # error mode.indexed_coins = [1, 2] producer._notify_if_missing_too_many_coins() error_mock.assert_called_once() error_mock.reset_mock() # error mode.indexed_coins = [] producer._notify_if_missing_too_many_coins() error_mock.assert_called_once() error_mock.reset_mock() async def test_ensure_index(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object( producer, "_wait_for_symbol_prices_and_profitability_init", mock.AsyncMock() ) as _wait_for_symbol_prices_and_profitability_init_mock, \ mock.patch.object(producer, "cancel_traded_pairs_open_orders_if_any", mock.AsyncMock(return_value=dependencies)) \ as _cancel_traded_pairs_open_orders_if_any: with mock.patch.object(producer, "_trigger_rebalance", mock.AsyncMock()) as _trigger_rebalance_mock: with mock.patch.object( producer, "_get_rebalance_details", mock.Mock(return_value=(False, {})) ) as _get_rebalance_details_mock: await producer.ensure_index() assert producer.last_activity == octobot_trading.modes.TradingModeActivity( index_trading.IndexActivity.REBALANCING_SKIPPED ) _cancel_traded_pairs_open_orders_if_any.assert_called_once() _cancel_traded_pairs_open_orders_if_any.reset_mock() _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once() _wait_for_symbol_prices_and_profitability_init_mock.reset_mock() _get_rebalance_details_mock.assert_called_once() _trigger_rebalance_mock.assert_not_called() with mock.patch.object( producer, "_get_rebalance_details", mock.Mock(return_value=(True, {"plop": 1})) ) as _get_rebalance_details_mock: await producer.ensure_index() assert producer.last_activity == octobot_trading.modes.TradingModeActivity( index_trading.IndexActivity.REBALANCING_DONE, {"plop": 1} ) _cancel_traded_pairs_open_orders_if_any.assert_called_once() _cancel_traded_pairs_open_orders_if_any.reset_mock() _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once() _wait_for_symbol_prices_and_profitability_init_mock.reset_mock() _get_rebalance_details_mock.assert_called_once() _trigger_rebalance_mock.assert_called_once_with({"plop": 1}, dependencies) _trigger_rebalance_mock.reset_mock() with mock.patch.object( producer, "_get_rebalance_details", mock.Mock(return_value=(True, {"plop": 1})) ) as _get_rebalance_details_mock: producer.trading_mode.cancel_open_orders = False await producer.ensure_index() assert producer.last_activity == octobot_trading.modes.TradingModeActivity( index_trading.IndexActivity.REBALANCING_DONE, {"plop": 1} ) _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once() _wait_for_symbol_prices_and_profitability_init_mock.reset_mock() _get_rebalance_details_mock.assert_called_once() _cancel_traded_pairs_open_orders_if_any.assert_not_called() _trigger_rebalance_mock.assert_called_once_with({"plop": 1}, None) # Test with requires_initializing_appropriate_coins_distribution = True with mock.patch.object(producer, "_trigger_rebalance", mock.AsyncMock()) as _trigger_rebalance_mock: with mock.patch.object( producer, "_get_rebalance_details", mock.Mock(return_value=(False, {})) ) as _get_rebalance_details_mock: with mock.patch.object( mode, "ensure_updated_coins_distribution", mock.Mock() ) as ensure_updated_coins_distribution_mock: # Set the flag to True mode.requires_initializing_appropriate_coins_distribution = True producer.trading_mode.cancel_open_orders = True await producer.ensure_index() # Verify ensure_updated_coins_distribution was called with adapt_to_holdings=True ensure_updated_coins_distribution_mock.assert_called_once_with(adapt_to_holdings=True) # Verify the flag was set to False assert mode.requires_initializing_appropriate_coins_distribution is False assert producer.last_activity == octobot_trading.modes.TradingModeActivity( index_trading.IndexActivity.REBALANCING_SKIPPED ) _cancel_traded_pairs_open_orders_if_any.assert_called_once() _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once() _get_rebalance_details_mock.assert_called_once() _trigger_rebalance_mock.assert_not_called() ensure_updated_coins_distribution_mock.reset_mock() _cancel_traded_pairs_open_orders_if_any.reset_mock() _wait_for_symbol_prices_and_profitability_init_mock.reset_mock() _get_rebalance_details_mock.reset_mock() with mock.patch.object( producer, "_get_rebalance_details", mock.Mock(return_value=(True, {"plop": 1})) ) as _get_rebalance_details_mock: with mock.patch.object( mode, "ensure_updated_coins_distribution", mock.Mock() ) as ensure_updated_coins_distribution_mock: # Set the flag to True and disable cancel_open_orders mode.requires_initializing_appropriate_coins_distribution = True producer.trading_mode.cancel_open_orders = False await producer.ensure_index() # Verify ensure_updated_coins_distribution was called with adapt_to_holdings=True ensure_updated_coins_distribution_mock.assert_called_once_with(adapt_to_holdings=True) # Verify the flag was set to False assert mode.requires_initializing_appropriate_coins_distribution is False assert producer.last_activity == octobot_trading.modes.TradingModeActivity( index_trading.IndexActivity.REBALANCING_DONE, {"plop": 1} ) _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once() _get_rebalance_details_mock.assert_called_once() _cancel_traded_pairs_open_orders_if_any.assert_not_called() _trigger_rebalance_mock.assert_called_once_with({"plop": 1}, None) ensure_updated_coins_distribution_mock.reset_mock() _wait_for_symbol_prices_and_profitability_init_mock.reset_mock() _get_rebalance_details_mock.reset_mock() _trigger_rebalance_mock.reset_mock() # Test with requires_initializing_appropriate_coins_distribution = False (default) with mock.patch.object(producer, "_trigger_rebalance", mock.AsyncMock()) as _trigger_rebalance_mock: with mock.patch.object( producer, "_get_rebalance_details", mock.Mock(return_value=(False, {})) ) as _get_rebalance_details_mock: with mock.patch.object( mode, "ensure_updated_coins_distribution", mock.Mock() ) as ensure_updated_coins_distribution_mock: # Ensure the flag is False (default state) mode.requires_initializing_appropriate_coins_distribution = False producer.trading_mode.cancel_open_orders = True await producer.ensure_index() # Verify ensure_updated_coins_distribution was NOT called ensure_updated_coins_distribution_mock.assert_not_called() # Verify the flag remains False assert mode.requires_initializing_appropriate_coins_distribution is False assert producer.last_activity == octobot_trading.modes.TradingModeActivity( index_trading.IndexActivity.REBALANCING_SKIPPED ) _cancel_traded_pairs_open_orders_if_any.assert_called_once() _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once() _get_rebalance_details_mock.assert_called_once() _trigger_rebalance_mock.assert_not_called() async def test_cancel_traded_pairs_open_orders_if_any(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) orders = [ mock.Mock(symbol="BTC/USDT"), mock.Mock(symbol="BTC/USDT"), mock.Mock(symbol="ETH/USDT"), mock.Mock(symbol="DOGE/USDT"), ] with mock.patch.object( trader.exchange_manager.exchange_personal_data.orders_manager, "get_open_orders", mock.Mock(return_value=orders) ) as get_open_orders_mock, \ mock.patch.object(mode, "cancel_order", mock.AsyncMock(return_value=(True, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])))) \ as cancel_order_mock: assert await producer.cancel_traded_pairs_open_orders_if_any() == trading_signals.get_orders_dependencies([mock.Mock(order_id="123"), mock.Mock(order_id="123")]) get_open_orders_mock.assert_called_once() assert cancel_order_mock.call_count == 2 assert cancel_order_mock.mock_calls[0].args[0] is orders[0] assert cancel_order_mock.mock_calls[1].args[0] is orders[1] async def test_trigger_rebalance(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object( producer, "submit_trading_evaluation", mock.AsyncMock() ) as _wait_for_symbol_prices_and_profitability_init_mock: details = {"hi": "ho"} await producer._trigger_rebalance(details, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _wait_for_symbol_prices_and_profitability_init_mock.assert_called_once_with( cryptocurrency=None, symbol=None, time_frame=None, final_note=None, state=trading_enums.EvaluatorStates.NEUTRAL, data=details, dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) async def test_get_rebalance_details(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "BTC/USDT", "SOL/USDT"] ] mode.ensure_updated_coins_distribution() mode.rebalance_trigger_min_ratio = decimal.Decimal("0.1") portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder with mock.patch.object(producer, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock: def _get_holdings_ratio(coin, **kwargs): if coin == "USDT": return decimal.Decimal("0") return decimal.Decimal("0.3") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: with mock.patch.object( mode, "get_removed_coins_from_config", mock.Mock(return_value=[]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is False assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() get_holdings_ratio_mock.reset_mock() with mock.patch.object( mode, "get_removed_coins_from_config", mock.Mock(return_value=["SOL", "ADA"]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: { "SOL": decimal.Decimal("0.3"), # "ADA": decimal.Decimal("0.3") # ADA is not in traded pairs, it's not removed }, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == \ len(mode.indexed_coins) + len(details[index_trading.RebalanceDetails.REMOVE.value]) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() get_holdings_ratio_mock.reset_mock() def _get_holdings_ratio(coin, **kwargs): if coin == "USDT": return decimal.Decimal("0") return decimal.Decimal("0.2") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: with mock.patch.object( mode, "get_removed_coins_from_config", mock.Mock(return_value=[]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() get_holdings_ratio_mock.reset_mock() with mock.patch.object( mode, "get_removed_coins_from_config", mock.Mock(return_value=["SOL", "ADA"]) ) as get_removed_coins_from_config_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, index_trading.RebalanceDetails.REMOVE.value: { "SOL": decimal.Decimal("0.2"), # "ADA": decimal.Decimal("0.2") # not in traded pairs }, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == \ len(mode.indexed_coins) + len(details[index_trading.RebalanceDetails.REMOVE.value]) + 1 # +1 for USDT get_removed_coins_from_config_mock.assert_called_once() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() get_holdings_ratio_mock.reset_mock() # rebalance cap larger than ratio def _get_holdings_ratio(coin, **kwargs): if coin == "USDT": return decimal.Decimal("0") return decimal.Decimal("0.3") mode.rebalance_trigger_min_ratio = decimal.Decimal("0.5") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is False assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() def _get_holdings_ratio(coin, **kwargs): if coin == "USDT": return decimal.Decimal("0") return decimal.Decimal("0.00000001") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is False assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() def _get_holdings_ratio(coin, **kwargs): if coin == "USDT": return decimal.Decimal("0") return decimal.Decimal("0.9") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(details[index_trading.RebalanceDetails.SELL_SOME.value]) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() def _get_holdings_ratio(coin, **kwargs): return decimal.Decimal("0") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == len(details[index_trading.RebalanceDetails.ADD.value]) + 1 # +1 for USDT get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() # will only add ETH def _get_holdings_ratio(coin, **kwargs): if coin == "ETH": return decimal.Decimal("0") return decimal.Decimal("0.33") with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: { 'ETH': decimal.Decimal('0.3333333333333333617834929233'), }, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert get_holdings_ratio_mock.call_count == 3 + 1 # called for each coin + 1 for USDT get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_called_once_with(details) _resolve_swaps_mock.reset_mock() async def test_get_rebalance_details_with_usdt_without_coin_distribution_update(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "BTC/USDT", "SOL/USDT"] ] mode.ensure_updated_coins_distribution() mode.rebalance_trigger_min_ratio = decimal.Decimal("0.1") mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder with mock.patch.object(producer, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock, \ mock.patch.object(mode, "ensure_updated_coins_distribution", mock.Mock()) as ensure_updated_coins_distribution_mock: def _get_holdings_ratio(coin, **kwargs): # USDT is 1/3 of the portfolio if coin == "USDT": return decimal.Decimal("0.33") # other coins are 2/3 of the portfolio return decimal.Decimal("0.33") * decimal.Decimal("2") / decimal.Decimal("3") # with added USDT to the portfolio with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, } assert get_holdings_ratio_mock.call_count == len(mode.indexed_coins) + 1 # called to check non-indexed assets ratio ensure_updated_coins_distribution_mock.assert_not_called() get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_not_called() _resolve_swaps_mock.reset_mock() async def test_get_rebalance_details_with_usdt_and_coin_distribution_update(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "BTC/USDT", "SOL/USDT"] ] mode.ensure_updated_coins_distribution() mode.rebalance_trigger_min_ratio = decimal.Decimal("0.1") portfolio_value_holder = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE with mock.patch.object(producer, "_resolve_swaps", mock.Mock()) as _resolve_swaps_mock, \ mock.patch.object(mode, "ensure_updated_coins_distribution", mock.Mock()) as ensure_updated_coins_distribution_mock: def _get_holdings_ratio(coin, **kwargs): # USDT is 1/3 of the portfolio if coin == "USDT": return decimal.Decimal("0.33") # other coins are 2/3 of the portfolio return decimal.Decimal("0.33") * decimal.Decimal("2") / decimal.Decimal("3") # with added USDT to the portfolio with mock.patch.object( portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=_get_holdings_ratio) ) as get_holdings_ratio_mock: should_rebalance, details = producer._get_rebalance_details() assert should_rebalance is True assert details == { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: { 'BTC': decimal.Decimal('0.3333333333333333617834929233'), 'ETH': decimal.Decimal('0.3333333333333333617834929233'), 'SOL': decimal.Decimal('0.3333333333333333617834929233') }, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, } # 2 x called to check non-indexed assets ratio (once for current and one for latest distribution) assert get_holdings_ratio_mock.call_count == 2 * (len(mode.indexed_coins) + 1) ensure_updated_coins_distribution_mock.assert_called_once() get_holdings_ratio_mock.reset_mock() _resolve_swaps_mock.assert_not_called() _resolve_swaps_mock.reset_mock() async def test_should_rebalance_due_to_non_indexed_quote_assets_ratio(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) assert mode.quote_asset_rebalance_ratio_threshold == decimal.Decimal("0.1") rebalance_details = { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.23"), rebalance_details) is True assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.1"), rebalance_details) is True assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.09"), rebalance_details) is False # lower threshold mode.quote_asset_rebalance_ratio_threshold = decimal.Decimal("0.05") assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.09"), rebalance_details) is True assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.04"), rebalance_details) is False # test added coins rebalance_details[index_trading.RebalanceDetails.ADD.value] = { "BTC": decimal.Decimal("0.1") } rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = { "ETH": decimal.Decimal("0.1") } # can't swap quote for BTC & ETH assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.1"), rebalance_details) is True # can swap quote for BTC & ETH: don't rebalance assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is False assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.21"), rebalance_details) is False assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.18"), rebalance_details) is False # beyond QUOTE_ASSET_TO_INDEXED_SWAP_RATIO_THRESHOLD threshold assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.17"), rebalance_details) is True # with removed coins: can't "just swap quote for added coins", perform regular quote ratio check rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = { "BTC": decimal.Decimal("0.1") } assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false # with sell some coins and removed coins: can't "just swap quote for added coins", perform regular quote ratio check rebalance_details[index_trading.RebalanceDetails.SELL_SOME.value] = { "BTC": decimal.Decimal("0.1") } assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false # with only sell some coin rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = {} assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.2"), rebalance_details) is True # is False when no coins are to remove assert producer._should_rebalance_due_to_non_indexed_quote_assets_ratio(decimal.Decimal("0.03"), rebalance_details) is False # bellow threshold: still false async def test_get_removed_coins_from_config_sell_removed_coins_asap(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE mode.sell_unindexed_traded_coins = False assert mode.get_removed_coins_from_config([]) == [] mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "AA" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "BB" } ] } assert mode.get_removed_coins_from_config([]) == [] mode.previous_trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "AA" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "BB" } ] } mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "AA" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "CC" } ] } assert mode.get_removed_coins_from_config([]) == ["BB"] # with sell_unindexed_traded_coins=True mode.sell_unindexed_traded_coins = True mode.indexed_coins = ["BTC"] mode.previous_trading_config = None assert mode.get_removed_coins_from_config(["BTC", "ETH"]) == ["ETH"] mode.previous_trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "AA" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "BB" } ] } assert sorted(mode.get_removed_coins_from_config(["BTC", "ETH"])) == sorted(["ETH", "BB"]) async def test_get_removed_coins_from_config_sell_removed_on_ratio_rebalance(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE mode.sell_unindexed_traded_coins = False assert mode.get_removed_coins_from_config([]) == [] # without historical config mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "SOL" } ] } assert mode.get_removed_coins_from_config([]) == [] # with sell_unindexed_traded_coins=True mode.sell_unindexed_traded_coins = True mode.indexed_coins = ["BTC"] assert mode.get_removed_coins_from_config(["BTC", "ETH"]) == ["ETH"] # with historical config historical_config_1 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ADA" } ] } historical_config_2 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC" }, { index_trading.index_distribution.DISTRIBUTION_NAME: "DOT" } ] } commons_configuration.add_historical_tentacle_config(mode.trading_config, 1, historical_config_1) commons_configuration.add_historical_tentacle_config(mode.trading_config, 2, historical_config_2) mode.historical_master_config = mode.trading_config with mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=0)): assert mode.get_removed_coins_from_config(["BTC", "ETH", "SOL"]) == ["ETH", "SOL"] with mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=2)): assert sorted(mode.get_removed_coins_from_config(["BTC", "ETH", "SOL"])) == sorted( ["ETH", "SOL", "ADA", "DOT"] ) assert sorted(mode.get_removed_coins_from_config(["BTC", "ETH"])) == sorted(['ADA', 'DOT', 'ETH']) # with sell_unindexed_traded_coins=False mode.sell_unindexed_traded_coins = False with mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=0)): assert mode.get_removed_coins_from_config(["BTC", "ETH", "SOL"]) == [] with mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=2)): assert sorted(mode.get_removed_coins_from_config(["BTC", "ETH", "SOL"])) == sorted( ["ADA", "DOT"] ) assert sorted(mode.get_removed_coins_from_config(["BTC", "ETH"])) == sorted(['ADA', 'DOT']) async def test_create_new_orders(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object( consumer, "_rebalance_portfolio", mock.AsyncMock(return_value="plop") ) as _rebalance_portfolio_mock: assert mode.is_processing_rebalance is False with pytest.raises(KeyError): # missing "data" await consumer.create_new_orders(None, None, None) assert await consumer.create_new_orders(None, None, None, data="hello", dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [] assert mode.is_processing_rebalance is False _rebalance_portfolio_mock.assert_not_called() assert await consumer.create_new_orders( None, None, trading_enums.EvaluatorStates.NEUTRAL.value, data="hello", dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) == "plop" _rebalance_portfolio_mock.assert_called_once_with("hello", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) assert mode.is_processing_rebalance is False async def test_rebalance_portfolio(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) sell_order = mock.Mock(order_id="456") with mock.patch.object( consumer, "_ensure_enough_funds_to_buy_after_selling", mock.AsyncMock() ) as _ensure_enough_funds_to_buy_after_selling_mock, mock.patch.object( consumer, "_sell_indexed_coins_for_reference_market", mock.AsyncMock(return_value=[sell_order]) ) as _sell_indexed_coins_for_reference_market_mock, mock.patch.object( consumer, "_split_reference_market_into_indexed_coins", mock.AsyncMock(return_value=["buy"]) ) as _split_reference_market_into_indexed_coins_mock: with mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=False) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [sell_order, "buy"] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_called_once_with("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _split_reference_market_into_indexed_coins_mock.assert_called_once_with("details", False, trading_signals.get_orders_dependencies([mock.Mock(order_id="456")])) _can_simply_buy_coins_without_selling_mock.assert_called_once_with("details") _ensure_enough_funds_to_buy_after_selling_mock.reset_mock() _sell_indexed_coins_for_reference_market_mock.reset_mock() _split_reference_market_into_indexed_coins_mock.reset_mock() with mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=True) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == ["buy"] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_not_called() _split_reference_market_into_indexed_coins_mock.assert_called_once_with("details", True, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _can_simply_buy_coins_without_selling_mock.assert_called_once_with("details") with mock.patch.object( consumer, "_update_producer_last_activity", mock.Mock() ) as _update_producer_last_activity_mock: with mock.patch.object( consumer, "_ensure_enough_funds_to_buy_after_selling", mock.AsyncMock() ) as _ensure_enough_funds_to_buy_after_selling_mock, mock.patch.object( consumer, "_sell_indexed_coins_for_reference_market", mock.AsyncMock( side_effect=trading_errors.MissingMinimalExchangeTradeVolume ) ) as _sell_indexed_coins_for_reference_market_mock, mock.patch.object( consumer, "_split_reference_market_into_indexed_coins", mock.AsyncMock(return_value=["buy"]) ) as _split_reference_market_into_indexed_coins_mock, mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=False) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_called_once_with("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _split_reference_market_into_indexed_coins_mock.assert_not_called() _can_simply_buy_coins_without_selling_mock.assert_called_once_with("details") _update_producer_last_activity_mock.assert_called_once_with( index_trading.IndexActivity.REBALANCING_SKIPPED, index_trading.RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) _update_producer_last_activity_mock.reset_mock() with mock.patch.object( consumer, "_ensure_enough_funds_to_buy_after_selling", mock.AsyncMock( side_effect=trading_errors.MissingMinimalExchangeTradeVolume ) ) as _ensure_enough_funds_to_buy_after_selling_mock, \ mock.patch.object( consumer, "_sell_indexed_coins_for_reference_market", mock.AsyncMock(return_value=[sell_order]) ) as _sell_indexed_coins_for_reference_market_mock, mock.patch.object( consumer, "_split_reference_market_into_indexed_coins", mock.AsyncMock(return_value=["buy"]) ) as _split_reference_market_into_indexed_coins_mock, mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=False) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_not_called() _split_reference_market_into_indexed_coins_mock.assert_not_called() _can_simply_buy_coins_without_selling_mock.assert_not_called() _update_producer_last_activity_mock.assert_called_once_with( index_trading.IndexActivity.REBALANCING_SKIPPED, index_trading.RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) _update_producer_last_activity_mock.reset_mock() with mock.patch.object( consumer, "_ensure_enough_funds_to_buy_after_selling", mock.AsyncMock() ) as _ensure_enough_funds_to_buy_after_selling_mock, \ mock.patch.object( consumer, "_sell_indexed_coins_for_reference_market", mock.AsyncMock(return_value=[sell_order]) ) as _sell_indexed_coins_for_reference_market_mock, mock.patch.object( consumer, "_split_reference_market_into_indexed_coins", mock.AsyncMock( side_effect=trading_errors.MissingMinimalExchangeTradeVolume ) ) as _split_reference_market_into_indexed_coins_mock: with mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=False) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [sell_order] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_called_once_with("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _split_reference_market_into_indexed_coins_mock.assert_called_once_with("details", False, trading_signals.get_orders_dependencies([mock.Mock(order_id="456")])) _update_producer_last_activity_mock.assert_called_once_with( index_trading.IndexActivity.REBALANCING_SKIPPED, index_trading.RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) _ensure_enough_funds_to_buy_after_selling_mock.reset_mock() _sell_indexed_coins_for_reference_market_mock.reset_mock() _split_reference_market_into_indexed_coins_mock.reset_mock() _update_producer_last_activity_mock.reset_mock() with mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=True) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_not_called() _split_reference_market_into_indexed_coins_mock.assert_called_once_with("details", True, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _update_producer_last_activity_mock.assert_called_once_with( index_trading.IndexActivity.REBALANCING_SKIPPED, index_trading.RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) _ensure_enough_funds_to_buy_after_selling_mock.reset_mock() _sell_indexed_coins_for_reference_market_mock.reset_mock() _split_reference_market_into_indexed_coins_mock.reset_mock() _update_producer_last_activity_mock.reset_mock() with mock.patch.object( consumer, "_ensure_enough_funds_to_buy_after_selling", mock.AsyncMock() ) as _ensure_enough_funds_to_buy_after_selling_mock, \ mock.patch.object( consumer, "_sell_indexed_coins_for_reference_market", mock.AsyncMock(return_value=[sell_order]) ) as _sell_indexed_coins_for_reference_market_mock, mock.patch.object( consumer, "_split_reference_market_into_indexed_coins", mock.AsyncMock( side_effect=index_trading.RebalanceAborted ) ) as _split_reference_market_into_indexed_coins_mock: with mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=False) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [sell_order] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_called_once_with("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _split_reference_market_into_indexed_coins_mock.assert_called_once_with("details", False, trading_signals.get_orders_dependencies([mock.Mock(order_id="456")])) _update_producer_last_activity_mock.assert_called_once_with( index_trading.IndexActivity.REBALANCING_SKIPPED, index_trading.RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) _ensure_enough_funds_to_buy_after_selling_mock.reset_mock() _sell_indexed_coins_for_reference_market_mock.reset_mock() _split_reference_market_into_indexed_coins_mock.reset_mock() _update_producer_last_activity_mock.reset_mock() with mock.patch.object( consumer, "_can_simply_buy_coins_without_selling", mock.Mock(return_value=True) ) as _can_simply_buy_coins_without_selling_mock: assert await consumer._rebalance_portfolio("details", trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == [] _ensure_enough_funds_to_buy_after_selling_mock.assert_called_once() _sell_indexed_coins_for_reference_market_mock.assert_not_called() _split_reference_market_into_indexed_coins_mock.assert_called_once_with("details", True, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) _update_producer_last_activity_mock.assert_called_once_with( index_trading.IndexActivity.REBALANCING_SKIPPED, index_trading.RebalanceSkipDetails.NOT_ENOUGH_AVAILABLE_FOUNDS.value ) _ensure_enough_funds_to_buy_after_selling_mock.reset_mock() _sell_indexed_coins_for_reference_market_mock.reset_mock() _split_reference_market_into_indexed_coins_mock.reset_mock() _update_producer_last_activity_mock.reset_mock() async def test_ensure_enough_funds_to_buy_after_selling(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=decimal.Decimal("2000")) ) as get_traded_assets_holdings_value_mock, mock.patch.object( consumer, "_get_symbols_and_amounts", mock.AsyncMock() ) as _get_symbols_and_amounts_mock: await consumer._ensure_enough_funds_to_buy_after_selling() get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) _get_symbols_and_amounts_mock.assert_called_once_with(["BTC"], decimal.Decimal("2000")) async def test_can_simply_buy_coins_without_selling(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) details = "details" with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=decimal.Decimal("2000")) ) as get_traded_assets_holdings_value_mock: # no coins to simply buy with mock.patch.object( consumer, "_get_simple_buy_coins", return_value=[] ) as _get_simple_buy_coins_mock, mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("160"))) ) as get_currency_portfolio_mock: assert consumer._can_simply_buy_coins_without_selling(details) is False _get_simple_buy_coins_mock.assert_called_once_with(details) get_traded_assets_holdings_value_mock.assert_not_called() get_currency_portfolio_mock.assert_not_called() # there are coins to simply buy with mock.patch.object( mode, "get_target_ratio", return_value=decimal.Decimal("0.25") ) as get_target_ratio_mock, mock.patch.object( consumer, "_get_simple_buy_coins", return_value=["BTC"] ) as _get_simple_buy_coins_mock: # not enough free funds in portfolio to buy for 25% of 2000 with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("160"))) ) as get_currency_portfolio_mock: assert consumer._can_simply_buy_coins_without_selling(details) is False _get_simple_buy_coins_mock.assert_called_once_with(details) get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) get_currency_portfolio_mock.assert_called_once_with("USDT") get_target_ratio_mock.assert_called_once_with("BTC") _get_simple_buy_coins_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() get_target_ratio_mock.reset_mock() # enough free funds in portfolio to buy for 25% of 2000 (using tolerance) with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("450"))) ) as get_currency_portfolio_mock: assert consumer._can_simply_buy_coins_without_selling(details) is True _get_simple_buy_coins_mock.assert_called_once_with(details) get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) get_currency_portfolio_mock.assert_called_once_with("USDT") get_target_ratio_mock.assert_called_once_with("BTC") _get_simple_buy_coins_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() get_target_ratio_mock.reset_mock() # more than enough free funds in portfolio to buy for 25% of 2000 with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("600.811"))) ) as get_currency_portfolio_mock: assert consumer._can_simply_buy_coins_without_selling(details) is True _get_simple_buy_coins_mock.assert_called_once_with(details) get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) get_currency_portfolio_mock.assert_called_once_with("USDT") get_target_ratio_mock.assert_called_once_with("BTC") _get_simple_buy_coins_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() get_target_ratio_mock.reset_mock() # now having multiple coins to buy with mock.patch.object( consumer, "_get_simple_buy_coins", return_value=["BTC", "ETH"] ) as _get_simple_buy_coins_mock: # enough funds for 1 but not 2 coins at 25% with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("600.811"))) ) as get_currency_portfolio_mock: assert consumer._can_simply_buy_coins_without_selling(details) is False _get_simple_buy_coins_mock.assert_called_once_with(details) get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) get_currency_portfolio_mock.assert_called_once_with("USDT") assert get_target_ratio_mock.call_count == 2 assert get_target_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_target_ratio_mock.mock_calls[1].args[0] == "ETH" _get_simple_buy_coins_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() get_target_ratio_mock.reset_mock() # enough funds for 2 coins at 25% with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("1000.811"))) ) as get_currency_portfolio_mock: assert consumer._can_simply_buy_coins_without_selling(details) is True _get_simple_buy_coins_mock.assert_called_once_with(details) get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) get_currency_portfolio_mock.assert_called_once_with("USDT") assert get_target_ratio_mock.call_count == 2 assert get_target_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_target_ratio_mock.mock_calls[1].args[0] == "ETH" _get_simple_buy_coins_mock.reset_mock() get_traded_assets_holdings_value_mock.reset_mock() get_target_ratio_mock.reset_mock() async def test_get_simple_buy_coins(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.indexed_coins = ["BTC", "ETH", "SOL"] assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"BTC": decimal.Decimal("0.2"), "ETH": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH"] # keep index coins order assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {"SOL": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2"), "BTC": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "SOL"] # TRX not in indexed coins: added at the end assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {"SOL": decimal.Decimal("0.1"), "TRX": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2"), "BTC": decimal.Decimal("0.5")}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "SOL", "TRX"] # don't return anything when other values are set assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {"BTC": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {"BTC": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {"BTC": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == [] # whatever is in other values, return [] when forced rebalance assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"ETH": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {"BTC": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, }) == [] # should return [BTC, ETH] but doesn't because of forced rebalance assert consumer._get_simple_buy_coins({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {"BTC": decimal.Decimal("0.2"), "ETH": decimal.Decimal("0.2")}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: True, }) == [] async def test_sell_indexed_coins_for_reference_market(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) orders = [ mock.Mock( symbol="BTC/USDT", side=trading_enums.TradeOrderSide.SELL ), mock.Mock( symbol="ETH/USDT", side=trading_enums.TradeOrderSide.SELL ) ] with mock.patch.object( octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=orders) ) as convert_assets_to_target_asset_mock, mock.patch.object( trading_personal_data, "wait_for_order_fill", mock.AsyncMock() ) as wait_for_order_fill_mock, mock.patch.object( consumer, "_get_coins_to_sell", mock.Mock(return_value=[1, 2, 3]) ) as _get_coins_to_sell_mock: details = { index_trading.RebalanceDetails.REMOVE.value: {} } assert await consumer._sell_indexed_coins_for_reference_market(details, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == orders convert_assets_to_target_asset_mock.assert_called_once_with( mode, [1, 2, 3], consumer.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {}, dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) assert wait_for_order_fill_mock.call_count == 2 _get_coins_to_sell_mock.assert_called_once_with(details) convert_assets_to_target_asset_mock.reset_mock() wait_for_order_fill_mock.reset_mock() _get_coins_to_sell_mock.reset_mock() # with valid remove coins details = { index_trading.RebalanceDetails.REMOVE.value: {"BTC": 0.01}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } assert await consumer._sell_indexed_coins_for_reference_market(details, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == orders + orders assert convert_assets_to_target_asset_mock.call_count == 2 assert wait_for_order_fill_mock.call_count == 4 _get_coins_to_sell_mock.assert_called_once_with(details) convert_assets_to_target_asset_mock.reset_mock() wait_for_order_fill_mock.reset_mock() _get_coins_to_sell_mock.reset_mock() with mock.patch.object( octobot_trading.modes, "convert_assets_to_target_asset", mock.AsyncMock(return_value=[]) ) as convert_assets_to_target_asset_mock_2: # with remove coins that can't be sold details = { index_trading.RebalanceDetails.REMOVE.value: {"BTC": 0.01}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, } with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): assert await consumer._sell_indexed_coins_for_reference_market(details, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) == orders + orders convert_assets_to_target_asset_mock_2.assert_called_once_with( mode, ["BTC"], consumer.exchange_manager.exchange_personal_data.portfolio_manager.reference_market, {}, dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) wait_for_order_fill_mock.assert_not_called() _get_coins_to_sell_mock.assert_not_called() async def test_get_coins_to_sell(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.indexed_coins = ["BTC", "ETH", "DOGE", "SHIB"] assert consumer._get_coins_to_sell({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "DOGE", "SHIB"] assert consumer._get_coins_to_sell({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: { "BTC": "ETH" }, }) == ["BTC"] assert consumer._get_coins_to_sell({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: { "XRP": trading_constants.ONE_HUNDRED }, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: { "BTC": "ETH", "SOL": "ADA", }, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "SOL"] assert consumer._get_coins_to_sell({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "DOGE", "SHIB"] assert consumer._get_coins_to_sell({ index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: { "XRP": trading_constants.ONE_HUNDRED }, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, index_trading.RebalanceDetails.FORCED_REBALANCE.value: False, }) == ["BTC", "ETH", "DOGE", "SHIB"] async def test_resolve_swaps(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) mode.rebalance_trigger_min_ratio = decimal.Decimal("0.05") # %5 rebalance_details = { index_trading.RebalanceDetails.SELL_SOME.value: {}, index_trading.RebalanceDetails.BUY_MORE.value: {}, index_trading.RebalanceDetails.REMOVE.value: {}, index_trading.RebalanceDetails.ADD.value: {}, index_trading.RebalanceDetails.SWAP.value: {}, } # regular full rebalance producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} # regular full rebalance with removed coins to sell rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = {"SOL": decimal.Decimal("0.3")} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} # rebalances with a coin swap only from ADD coin rebalance_details[index_trading.RebalanceDetails.ADD.value] = {"ADA": decimal.Decimal("0.3")} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {"SOL": "ADA"} # rebalances with a coin swap only from BUY_MORE coin rebalance_details[index_trading.RebalanceDetails.ADD.value] = {} rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {"ADA": decimal.Decimal("0.3")} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {"SOL": "ADA"} rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {} # rebalances with an incompatible coin swap (ratio too different) rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {"ADA": decimal.Decimal("0.1")} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {} # rebalances with an incompatible coin swap (ratio too different) rebalance_details[index_trading.RebalanceDetails.ADD.value] = {"ADA": decimal.Decimal("0.5")} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} # rebalances with 2 removed coins: sell everything rebalance_details[index_trading.RebalanceDetails.REMOVE.value] = { "SOL": decimal.Decimal("0.3"), "XRP": decimal.Decimal("0.3"), } producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} # rebalances with 2 coin swaps: sell everything rebalance_details[index_trading.RebalanceDetails.ADD.value] = { "ADA": decimal.Decimal("0.3"), "ADA2": decimal.Decimal("0.3"), } producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} # rebalance with regular buy / sell more rebalance_details[index_trading.RebalanceDetails.BUY_MORE.value] = {"LTC": decimal.Decimal(1)} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} # rebalance with regular buy / sell more rebalance_details[index_trading.RebalanceDetails.SELL_SOME.value] = {"BTC": decimal.Decimal(1)} producer._resolve_swaps(rebalance_details) assert rebalance_details[index_trading.RebalanceDetails.SWAP.value] == {} async def test_split_reference_market_into_indexed_coins(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) # no indexed coin mode.indexed_coins = [] details = {index_trading.RebalanceDetails.SWAP.value: {}} is_simple_buy_without_selling = False dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object( consumer, "_get_symbols_and_amounts", mock.AsyncMock( side_effect=lambda coins, _: {f"{coin}/USDT": decimal.Decimal(i + 1) for i, coin in enumerate(coins)} ) ) as _get_symbols_and_amounts_mock: with mock.patch.object( consumer, "_get_simple_buy_coins", mock.Mock() ) as _get_simple_buy_coins_mock: with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("2"))) ) as get_currency_portfolio_mock, mock.patch.object( consumer, "_buy_coin", mock.AsyncMock(return_value=["order"]) ) as _buy_coin_mock: with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): await consumer._split_reference_market_into_indexed_coins(details, is_simple_buy_without_selling, dependencies) get_currency_portfolio_mock.assert_called_once_with("USDT") _buy_coin_mock.assert_not_called() _get_symbols_and_amounts_mock.assert_called_once() _get_symbols_and_amounts_mock.reset_mock() _get_simple_buy_coins_mock.assert_not_called() # coins to swap mode.indexed_coins = [] details = {index_trading.RebalanceDetails.SWAP.value: {"BTC": "ETH", "ADA": "SOL"}} with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("2"))) ) as get_currency_portfolio_mock, mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=decimal.Decimal("2000")) ) as get_traded_assets_holdings_value_mock, mock.patch.object( consumer, "_buy_coin", mock.AsyncMock(return_value=["order"]) ) as _buy_coin_mock: assert await consumer._split_reference_market_into_indexed_coins( details, is_simple_buy_without_selling, dependencies ) == ["order", "order"] _get_symbols_and_amounts_mock.assert_called_once() _get_symbols_and_amounts_mock.reset_mock() get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) get_currency_portfolio_mock.assert_not_called() _get_simple_buy_coins_mock.assert_not_called() assert _buy_coin_mock.call_count == 2 assert _buy_coin_mock.mock_calls[0].args == ("ETH/USDT", decimal.Decimal("1"), dependencies) assert _buy_coin_mock.mock_calls[1].args == ("SOL/USDT", decimal.Decimal("2"), dependencies) # no bought coin details = {index_trading.RebalanceDetails.SWAP.value: {}} mode.indexed_coins = ["ETH", "BTC"] with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("2"))) ) as get_currency_portfolio_mock, mock.patch.object( consumer, "_buy_coin", mock.AsyncMock(return_value=[]) ) as _buy_coin_mock: with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): await consumer._split_reference_market_into_indexed_coins(details, is_simple_buy_without_selling, dependencies) _get_symbols_and_amounts_mock.assert_called_once() _get_symbols_and_amounts_mock.reset_mock() get_currency_portfolio_mock.assert_called_once_with("USDT") _get_simple_buy_coins_mock.assert_not_called() assert _buy_coin_mock.call_count == 2 # bought coins mode.indexed_coins = ["ETH", "BTC"] with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("2"))) ) as get_currency_portfolio_mock, mock.patch.object( consumer, "_buy_coin", mock.AsyncMock(return_value=["order"]) ) as _buy_coin_mock: assert await consumer._split_reference_market_into_indexed_coins( details, is_simple_buy_without_selling, dependencies ) == ["order", "order"] _get_symbols_and_amounts_mock.assert_called_once() _get_symbols_and_amounts_mock.reset_mock() get_currency_portfolio_mock.assert_called_once_with("USDT") _get_simple_buy_coins_mock.assert_not_called() assert _buy_coin_mock.call_count == 2 assert _buy_coin_mock.mock_calls[0].args[0] == "ETH/USDT" assert _buy_coin_mock.mock_calls[0].args[2] == dependencies assert _buy_coin_mock.mock_calls[1].args[0] == "BTC/USDT" assert _buy_coin_mock.mock_calls[1].args[2] == dependencies with mock.patch.object( consumer, "_get_simple_buy_coins", mock.Mock(return_value=["ETH"]) ) as _get_simple_buy_coins_mock: # simple buy without selling => buying only ETH is_simple_buy_without_selling = True mode.indexed_coins = ["ETH", "BTC"] with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio, "get_currency_portfolio", mock.Mock(return_value=mock.Mock(available=decimal.Decimal("2"))) ) as get_currency_portfolio_mock, mock.patch.object( consumer, "_buy_coin", mock.AsyncMock(return_value=["order"]) ) as _buy_coin_mock, mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_traded_assets_holdings_value", mock.Mock(return_value=decimal.Decimal("2000")) ) as get_traded_assets_holdings_value_mock: assert await consumer._split_reference_market_into_indexed_coins( details, is_simple_buy_without_selling, dependencies ) == ["order"] _get_symbols_and_amounts_mock.assert_called_once() _get_symbols_and_amounts_mock.reset_mock() get_currency_portfolio_mock.assert_not_called() get_traded_assets_holdings_value_mock.assert_called_once_with("USDT", None) _get_simple_buy_coins_mock.assert_called_once_with(details) assert _buy_coin_mock.call_count == 1 assert _buy_coin_mock.mock_calls[0].args[0] == "ETH/USDT" assert _buy_coin_mock.mock_calls[0].args[2] == dependencies async def test_get_symbols_and_amounts(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["BTC/USDT"] ] mode.ensure_updated_coins_distribution() assert await consumer._get_symbols_and_amounts(["BTC"], decimal.Decimal(3000)) == { "BTC/USDT": decimal.Decimal(3) } with mock.patch.object( trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=decimal.Decimal(1000)) ) as get_up_to_date_price_mock: assert await consumer._get_symbols_and_amounts(["BTC", "ETH"], decimal.Decimal(3000)) == { "BTC/USDT": decimal.Decimal(3) } assert get_up_to_date_price_mock.call_count == 2 get_up_to_date_price_mock.reset_mock() trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["BTC/USDT", "ETH/USDT"] ] mode.ensure_updated_coins_distribution() assert await consumer._get_symbols_and_amounts(["BTC", "ETH"], decimal.Decimal(3000)) == { "BTC/USDT": decimal.Decimal("1.5"), "ETH/USDT": decimal.Decimal("1.5") } assert get_up_to_date_price_mock.call_count == 2 # not enough funds with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): await consumer._get_symbols_and_amounts(["BTC"], decimal.Decimal(0.0003)) with mock.patch.object( trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=decimal.Decimal(0.000000001)) ) as get_up_to_date_price_mock: with pytest.raises(trading_errors.MissingMinimalExchangeTradeVolume): await consumer._get_symbols_and_amounts(["BTC", "ETH"], decimal.Decimal(0.01)) assert get_up_to_date_price_mock.call_count == 1 # with ref market in coins config mode.trading_config = { "index_content": [ { "name": "BTC", "value": 70 }, { "name": "USDT", "value": 30 } ], "refresh_interval": 1, "required_strategies": [], "rebalance_trigger_min_percent": 5 } mode.ensure_updated_coins_distribution() with mock.patch.object( trading_personal_data, "get_up_to_date_price", mock.AsyncMock(return_value=decimal.Decimal(1000)) ) as get_up_to_date_price_mock: # USDT is not counted in orders to create (nothing to buy as USDT is the reference market everything is sold to) assert await consumer._get_symbols_and_amounts(["BTC", "USDT"], decimal.Decimal(3000)) == { "BTC/USDT": decimal.Decimal("2.1") } assert get_up_to_date_price_mock.call_count == 1 async def test_buy_coin(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object(mode, "create_order", mock.AsyncMock(side_effect=lambda x, **kwargs: x)) as create_order_mock: # coin already held portfolio["BTC"].available = decimal.Decimal(20) assert await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) == [] create_order_mock.assert_not_called() # coin already partially held portfolio["BTC"].available = decimal.Decimal(0.5) orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) assert len(orders) == 1 create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyMarketOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1000) assert orders[0].origin_quantity == decimal.Decimal("1.5") assert orders[0].total_cost == decimal.Decimal("1500") create_order_mock.reset_mock() # coin not already held portfolio["BTC"].available = decimal.Decimal(0) orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) assert len(orders) == 1 create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyMarketOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1000) assert orders[0].origin_quantity == decimal.Decimal(2) assert orders[0].total_cost == decimal.Decimal("2000") create_order_mock.reset_mock() # given ideal_amount is lower orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal("0.025"), dependencies) assert len(orders) == 1 create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyMarketOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1000) assert orders[0].origin_quantity == decimal.Decimal("0.025") # use 100 instead of all 2000 USDT in pf assert orders[0].total_cost == decimal.Decimal("25") create_order_mock.reset_mock() # adapt for fees fee_usdt_cost = decimal.Decimal(10) with mock.patch.object( consumer.exchange_manager.exchange, "get_trade_fee", mock.Mock(return_value={ trading_enums.FeePropertyColumns.COST.value: str(fee_usdt_cost), trading_enums.FeePropertyColumns.CURRENCY.value: "USDT", }) ) as get_trade_fee_mock: orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal("0.5"), dependencies) assert get_trade_fee_mock.call_count == 2 assert len(orders) == 1 create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyMarketOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1000) # no adaptation needed as not all funds are used (1/4 ratio) assert orders[0].origin_quantity == decimal.Decimal("0.5") assert orders[0].total_cost == decimal.Decimal("500") create_order_mock.reset_mock() get_trade_fee_mock.reset_mock() orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) assert get_trade_fee_mock.call_count == 2 assert len(orders) == 1 create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyMarketOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1000) btc_fees = fee_usdt_cost / orders[0].origin_price # 2 - fees denominated in BTC assert orders[0].origin_quantity == decimal.Decimal("2") - btc_fees * trading_constants.FEES_SAFETY_MARGIN assert orders[0].total_cost == decimal.Decimal('1987.5000') create_order_mock.reset_mock() async def test_buy_coin_using_limit_order(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object( mode, "create_order", mock.AsyncMock(side_effect=lambda x, **kwargs: x) ) as create_order_mock, mock.patch.object( mode.exchange_manager.exchange, "is_market_open_for_order_type", mock.Mock(return_value=False) ) as is_market_open_for_order_type_mock: # coin already held portfolio["BTC"].available = decimal.Decimal(20) assert await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) == [] create_order_mock.assert_not_called() is_market_open_for_order_type_mock.assert_not_called() # coin already partially held: buy more using limit order portfolio["BTC"].available = decimal.Decimal(0.5) orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) assert len(orders) == 1 is_market_open_for_order_type_mock.assert_called_once_with("BTC/USDT", trading_enums.TraderOrderType.BUY_MARKET) create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyLimitOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1005) # a bit above market price to instant fill assert orders[0].origin_quantity == decimal.Decimal('1.49253731') # reduced a bit to compensate price increase assert decimal.Decimal("1499.99999") < orders[0].total_cost < decimal.Decimal("1500") create_order_mock.reset_mock() is_market_open_for_order_type_mock.reset_mock() # coin not already held portfolio["BTC"].available = decimal.Decimal(0) orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) assert len(orders) == 1 is_market_open_for_order_type_mock.assert_called_once_with("BTC/USDT", trading_enums.TraderOrderType.BUY_MARKET) create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyLimitOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal('1005.000') assert orders[0].origin_quantity == decimal.Decimal('1.99004975') assert decimal.Decimal("1999.99999") < orders[0].total_cost < decimal.Decimal("2000") create_order_mock.reset_mock() is_market_open_for_order_type_mock.reset_mock() # given ideal_amount is lower orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal("0.025"), dependencies) assert len(orders) == 1 is_market_open_for_order_type_mock.assert_called_once_with("BTC/USDT", trading_enums.TraderOrderType.BUY_MARKET) create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyLimitOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1005) assert orders[0].origin_quantity == decimal.Decimal('0.02487562') # use 100 instead of all 2000 USDT in pf assert decimal.Decimal('24.999') < orders[0].total_cost < decimal.Decimal("25") create_order_mock.reset_mock() is_market_open_for_order_type_mock.reset_mock() # adapt for fees fee_usdt_cost = decimal.Decimal(10) with mock.patch.object( consumer.exchange_manager.exchange, "get_trade_fee", mock.Mock(return_value={ trading_enums.FeePropertyColumns.COST.value: str(fee_usdt_cost), trading_enums.FeePropertyColumns.CURRENCY.value: "USDT", }) ) as get_trade_fee_mock: orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal("0.5"), dependencies) assert get_trade_fee_mock.call_count == 2 assert len(orders) == 1 is_market_open_for_order_type_mock.assert_called_once_with("BTC/USDT", trading_enums.TraderOrderType.BUY_MARKET) create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyLimitOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1005) # no adaptation needed as not all funds are used (1/4 ratio) assert orders[0].origin_quantity == decimal.Decimal('0.49751243') assert decimal.Decimal('499.999') < orders[0].total_cost < decimal.Decimal("500") create_order_mock.reset_mock() get_trade_fee_mock.reset_mock() is_market_open_for_order_type_mock.reset_mock() orders = await consumer._buy_coin("BTC/USDT", decimal.Decimal(2), dependencies) assert get_trade_fee_mock.call_count == 2 assert len(orders) == 1 is_market_open_for_order_type_mock.assert_called_once_with("BTC/USDT", trading_enums.TraderOrderType.BUY_MARKET) create_order_mock.assert_called_once_with(orders[0], dependencies=dependencies) assert isinstance(orders[0], trading_personal_data.BuyLimitOrder) assert orders[0].symbol == "BTC/USDT" assert orders[0].origin_price == decimal.Decimal(1005) # 2 - fees denominated in BTC symbol_market = trader.exchange_manager.exchange.get_market_status(orders[0].symbol, with_fixer=False) assert orders[0].origin_quantity == trading_personal_data.decimal_adapt_quantity( symbol_market, ( decimal.Decimal("2000") - fee_usdt_cost * trading_constants.FEES_SAFETY_MARGIN ) / orders[0].origin_price ) assert decimal.Decimal('1985') < orders[0].total_cost < decimal.Decimal('1990') create_order_mock.reset_mock() is_market_open_for_order_type_mock.reset_mock() async def _get_tools(symbol="BTC/USDT"): config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator( exchange_manager.config, exchange_manager, backtesting ) await exchange_manager.exchange.initialize() exchange_manager.exchange_config.set_config_traded_pairs() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() exchange_manager.exchange_personal_data.portfolio_manager.reference_market = "USDT" mode = Mode.IndexTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol # trading mode is not initialized: to be initialized with the required config in tests # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) return mode, trader async def _init_mode(tools, config): mode, trader = tools await mode.initialize(trading_config=config) return mode, mode.producers[0], mode.get_trading_mode_consumers()[0], trader async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() async def test_automatically_update_historical_config_on_set_intervals(tools): update = {} mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, update)) # Test with SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE policy mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE with mock.patch.object(mode, "supports_historical_config", mock.Mock(return_value=True)) as supports_historical_config_mock: assert mode.automatically_update_historical_config_on_set_intervals() is True supports_historical_config_mock.assert_called_once() supports_historical_config_mock.reset_mock() with mock.patch.object(mode, "supports_historical_config", mock.Mock(return_value=False)) as supports_historical_config_mock: assert mode.automatically_update_historical_config_on_set_intervals() is False supports_historical_config_mock.assert_called_once() supports_historical_config_mock.reset_mock() # Test with SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE policy mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE with mock.patch.object(mode, "supports_historical_config", mock.Mock(return_value=True)) as supports_historical_config_mock: assert mode.automatically_update_historical_config_on_set_intervals() is False supports_historical_config_mock.assert_called_once() supports_historical_config_mock.reset_mock() with mock.patch.object(mode, "supports_historical_config", mock.Mock(return_value=False)) as supports_historical_config_mock: assert mode.automatically_update_historical_config_on_set_intervals() is False supports_historical_config_mock.assert_called_once() supports_historical_config_mock.reset_mock() async def test_ensure_updated_coins_distribution(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["ETH/USDT", "SOL/USDT", "BTC/USDT"] ] distribution = [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "SOL", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, ] with mock.patch.object(mode, "_get_supported_distribution", mock.Mock(return_value=distribution)) as _get_supported_distribution_mock: mode.ensure_updated_coins_distribution() _get_supported_distribution_mock.assert_called_once() _get_supported_distribution_mock.reset_mock() assert mode.ratio_per_asset == { "BTC": { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, "ETH": { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, "SOL": { index_trading.index_distribution.DISTRIBUTION_NAME: "SOL", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 } } assert mode.total_ratio_per_asset == 100 assert mode.indexed_coins == ["BTC", "ETH", "SOL"] # include ref market in distribution distribution = [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, ] with mock.patch.object(mode, "_get_supported_distribution", mock.Mock(return_value=distribution)) as _get_supported_distribution_mock: mode.ensure_updated_coins_distribution() _get_supported_distribution_mock.assert_called_once() _get_supported_distribution_mock.reset_mock() assert mode.ratio_per_asset == { "BTC": { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, "ETH": { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, "USDT": { index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 } } assert mode.total_ratio_per_asset == 100 assert mode.indexed_coins == ["BTC", "ETH", "USDT"] async def test_get_supported_distribution(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["BTC/USDT", "ETH/USDT", "SOL/USDT", "ADA/USDT"] ] mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 25 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 25 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "SOL", index_trading.index_distribution.DISTRIBUTION_VALUE: 25 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ADA", index_trading.index_distribution.DISTRIBUTION_VALUE: 25 }, ] } with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: # no ideal distribution: return uniform distribution over traded assets assert mode._get_supported_distribution(False, False) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, ] } with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: assert mode._get_supported_distribution(False, False) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "PLOP", # not traded index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, ] } with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: assert mode._get_supported_distribution(False, False) == [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, # { # index_trading.index_distribution.DISTRIBUTION_NAME: "PLOP", # not traded # index_trading.index_distribution.DISTRIBUTION_VALUE: 20 # }, ] get_ideal_distribution_mock.assert_called_once() mode.trading_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "USDT", index_trading.index_distribution.DISTRIBUTION_VALUE: 20 }, ] } # synchronization policy is not SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_AS_SOON_AS_POSSIBLE with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock()) as get_historical_configs_mock: assert mode._get_supported_distribution(True, False) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() _get_currently_applied_historical_config_according_to_holdings_mock.assert_not_called() get_historical_configs_mock.assert_not_called() _get_currently_applied_historical_config_according_to_holdings_mock.reset_mock() get_historical_configs_mock.reset_mock() get_ideal_distribution_mock.reset_mock() assert mode._get_supported_distribution(False, True) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] get_ideal_distribution_mock.assert_called_once() _get_currently_applied_historical_config_according_to_holdings_mock.assert_not_called() get_historical_configs_mock.assert_not_called() # synchronization policy is SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE mode.synchronization_policy = index_trading.SynchronizationPolicy.SELL_REMOVED_INDEX_COINS_ON_RATIO_REBALANCE holding_adapted_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, ] } with mock.patch.object(mode, "get_ideal_distribution", mock.Mock(wraps=mode.get_ideal_distribution)) as get_ideal_distribution_mock: with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock(return_value=holding_adapted_config)) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock()) as get_historical_configs_mock: assert mode._get_supported_distribution(True, False) == holding_adapted_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] assert get_ideal_distribution_mock.call_count == 2 _get_currently_applied_historical_config_according_to_holdings_mock.assert_called_once_with( mode.trading_config, {'ADA', 'BTC', 'SOL', 'USDT', 'ETH'} ) get_historical_configs_mock.assert_not_called() get_ideal_distribution_mock.reset_mock() # with historical configs latest_config = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, ] } historical_configs = [ latest_config, holding_adapted_config, ] with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock(return_value=historical_configs)) as get_historical_configs_mock: assert mode._get_supported_distribution(False, True) == latest_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] assert get_ideal_distribution_mock.call_count == 3 _get_currently_applied_historical_config_according_to_holdings_mock.assert_not_called() get_historical_configs_mock.assert_called_once_with( 0, mode.exchange_manager.exchange.get_exchange_current_time() ) get_ideal_distribution_mock.reset_mock() # without historical configs with mock.patch.object(mode, "_get_currently_applied_historical_config_according_to_holdings", mock.Mock()) as _get_currently_applied_historical_config_according_to_holdings_mock, \ mock.patch.object(mode, "get_historical_configs", mock.Mock(return_value=[])) as get_historical_configs_mock: # use current config assert mode._get_supported_distribution(False, True) == mode.trading_config[ index_trading.IndexTradingModeProducer.INDEX_CONTENT ] assert get_ideal_distribution_mock.call_count == 2 _get_currently_applied_historical_config_according_to_holdings_mock.assert_not_called() get_historical_configs_mock.assert_called_once_with( 0, mode.exchange_manager.exchange.get_exchange_current_time() ) get_ideal_distribution_mock.reset_mock() async def test_get_currently_applied_historical_config_according_to_holdings(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["BTC/USDT", "ETH/USDT", "SOL/USDT", "ADA/USDT"] ] traded_bases = set( symbol.base for symbol in trader.exchange_manager.exchange_config.traded_symbols ) # 1. using latest config with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(return_value=True)) as _is_index_config_applied_mock: assert mode._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == mode.trading_config _is_index_config_applied_mock.assert_called_once_with(mode.trading_config, traded_bases) # 2. using historical configs with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(return_value=False)) as _is_index_config_applied_mock, mock.patch.object(mode.exchange_manager.exchange, "get_exchange_current_time", mock.Mock(return_value=2)) as get_exchange_current_time_mock: # 2.1. no historical configs assert mode._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == mode.trading_config _is_index_config_applied_mock.assert_called_once_with(mode.trading_config, traded_bases) _is_index_config_applied_mock.reset_mock() get_exchange_current_time_mock.assert_called_once() get_exchange_current_time_mock.reset_mock() # 2.2. with historical configs but as _is_index_config_applied always return False, fallback to current config hist_config_1 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, ] } hist_config_2 = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, ] } commons_configuration.add_historical_tentacle_config(mode.trading_config, 1, hist_config_1) commons_configuration.add_historical_tentacle_config(mode.trading_config, 2, hist_config_2) mode.historical_master_config = mode.trading_config assert mode._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == mode.trading_config assert _is_index_config_applied_mock.call_count == 3 assert _is_index_config_applied_mock.mock_calls[0].args[0] == mode.trading_config assert _is_index_config_applied_mock.mock_calls[1].args[0] == hist_config_2 assert _is_index_config_applied_mock.mock_calls[2].args[0] == hist_config_1 _is_index_config_applied_mock.reset_mock() get_exchange_current_time_mock.assert_called_once() get_exchange_current_time_mock.reset_mock() __is_index_config_applied_calls = [] accepted_config_index = 1 def __is_index_config_applied(*args): __is_index_config_applied_calls.append(1) if len(__is_index_config_applied_calls) - 1 >= accepted_config_index: return True return False # 2.3. with historical configs using historical config with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(side_effect=__is_index_config_applied)) as _is_index_config_applied_mock: # 1. use most up to date config assert mode._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == hist_config_2 assert _is_index_config_applied_mock.call_count == 2 assert _is_index_config_applied_mock.mock_calls[0].args[0] == mode.trading_config assert _is_index_config_applied_mock.mock_calls[1].args[0] == hist_config_2 _is_index_config_applied_mock.reset_mock() get_exchange_current_time_mock.assert_called_once() get_exchange_current_time_mock.reset_mock() __is_index_config_applied_calls.clear() accepted_config_index = 2 with mock.patch.object(mode, "_is_index_config_applied", mock.Mock(side_effect=__is_index_config_applied)) as _is_index_config_applied_mock: # 2. use oldest config assert mode._get_currently_applied_historical_config_according_to_holdings( mode.trading_config, traded_bases ) == hist_config_1 assert _is_index_config_applied_mock.call_count == 3 assert _is_index_config_applied_mock.mock_calls[0].args[0] == mode.trading_config assert _is_index_config_applied_mock.mock_calls[1].args[0] == hist_config_2 assert _is_index_config_applied_mock.mock_calls[2].args[0] == hist_config_1 _is_index_config_applied_mock.reset_mock() async def test_is_index_config_applied(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) trader.exchange_manager.exchange_config.traded_symbols = [ commons_symbols.parse_symbol(symbol) for symbol in ["BTC/USDT", "ETH/USDT", "SOL/USDT", "ADA/USDT"] ] traded_bases = set( symbol.base for symbol in trader.exchange_manager.exchange_config.traded_symbols ) # Test 1: No ideal distribution - should return False config_without_distribution = {} assert mode._is_index_config_applied(config_without_distribution, traded_bases) is False # Test 2: Empty ideal distribution - should return False config_with_empty_distribution = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [] } assert mode._is_index_config_applied(config_with_empty_distribution, traded_bases) is False # Test 3: Distribution with only non-traded assets - should return False config_with_non_traded_assets = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_COIN", index_trading.index_distribution.DISTRIBUTION_VALUE: 100 } ] } assert mode._is_index_config_applied(config_with_non_traded_assets, traded_bases) is False # Test 4: Distribution with zero total ratio - should return False config_with_zero_total = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 0 } ] } assert mode._is_index_config_applied(config_with_zero_total, traded_bases) is False # Test 5: Valid distribution with holdings matching target ratios config_with_valid_distribution = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 60 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 40 } ] } # Mock holdings ratios to match target ratios exactly with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.6"), # 60% target "ETH": decimal.Decimal("0.4"), # 40% target }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 assert get_holdings_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_holdings_ratio_mock.mock_calls[1].args[0] == "ETH" get_holdings_ratio_mock.reset_mock() # Test 6: Valid distribution with holdings within tolerance range with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.62"), # 60% target + 2% (within 5% tolerance) "ETH": decimal.Decimal("0.38"), # 40% target - 2% (within 5% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() # Test 7: Holdings outside tolerance range - should return False with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.68"), # 60% target + 8% (outside 5% tolerance) "ETH": decimal.Decimal("0.32"), # 40% target - 8% (outside 5% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC is considered get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) get_holdings_ratio_mock.reset_mock() # Test 8: Missing coin in portfolio - should return False with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.6"), # 60% target "ETH": decimal.Decimal("0"), # Missing ETH }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() # Test 9: Too much of a coin in portfolio - should return False with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.6"), # 60% target: OK "ETH": decimal.Decimal("0.3"), # 40% target - 10% (too little) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 2 # BTC and ETH considered assert get_holdings_ratio_mock.mock_calls[0].args[0] == "BTC" assert get_holdings_ratio_mock.mock_calls[1].args[0] == "ETH" get_holdings_ratio_mock.reset_mock() # Test 10a: Custom rebalance trigger ratio in config from REBALANCE_TRIGGER_MIN_PERCENT config_with_custom_trigger = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 } ], index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 10.0 # 10% tolerance } # Holdings within 10% tolerance with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.57"), # 50% target + 7% (within 10% tolerance) "ETH": decimal.Decimal("0.43"), # 50% target - 7% (within 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() # Holdings outside 10% tolerance with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.65"), # 50% target + 15% (outside 10% tolerance) "ETH": decimal.Decimal("0.35"), # 50% target - 15% (outside 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC is considered get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) get_holdings_ratio_mock.reset_mock() # Test 10b: Custom rebalance trigger ratio in config from REBALANCE_TRIGGER_MIN_PERCENT config_with_custom_trigger = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 } ], index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 10.0 # 10% tolerance } ], index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 99.0 # 99% tolerance } # Holdings within 10% tolerance (profile 1) with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.57"), # 50% target + 7% (within 10% tolerance) "ETH": decimal.Decimal("0.43"), # 50% target - 7% (within 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is True assert get_holdings_ratio_mock.call_count == 2 get_holdings_ratio_mock.reset_mock() # Holdings outside 10% tolerance (profile 1) with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.65"), # 50% target + 15% (outside 10% tolerance) "ETH": decimal.Decimal("0.35"), # 50% target - 15% (outside 10% tolerance) }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_custom_trigger, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC is considered get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) get_holdings_ratio_mock.reset_mock() # Test 11: Mixed traded and non-traded assets config_with_mixed_assets = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "BTC", index_trading.index_distribution.DISTRIBUTION_VALUE: 60 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "ETH", index_trading.index_distribution.DISTRIBUTION_VALUE: 30 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_COIN", index_trading.index_distribution.DISTRIBUTION_VALUE: 10 } ] } # Should only consider traded assets (BTC and ETH) with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(side_effect=lambda coin, **kwargs: { "BTC": decimal.Decimal("0.6666666666666666666666666667"), # 60/90 = 66.67% "ETH": decimal.Decimal("0.3333333333333333333333333333"), # 30/90 = 33.33% }.get(coin, decimal.Decimal("0"))) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_mixed_assets, traded_bases) is False get_holdings_ratio_mock.assert_not_called() # Test 12: All assets non-traded config_all_non_traded = { index_trading.IndexTradingModeProducer.INDEX_CONTENT: [ { index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_1", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 }, { index_trading.index_distribution.DISTRIBUTION_NAME: "NON_TRADED_2", index_trading.index_distribution.DISTRIBUTION_VALUE: 50 } ] } assert mode._is_index_config_applied(config_all_non_traded, traded_bases) is False # Test 13: Zero holdings for all coins with mock.patch.object( trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder, "get_holdings_ratio", mock.Mock(return_value=decimal.Decimal("0")) ) as get_holdings_ratio_mock: assert mode._is_index_config_applied(config_with_valid_distribution, traded_bases) is False assert get_holdings_ratio_mock.call_count == 1 # only BTC considered get_holdings_ratio_mock.assert_called_once_with("BTC", traded_symbols_only=True) get_holdings_ratio_mock.reset_mock() async def test_get_config_min_ratio(tools): mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {})) # 1. With selected profile config_with_profiles = { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 7.5, }, { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-2", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 15.0, }, ], index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "profile-2", } # Should pick 15.0% from profile-2 assert mode._get_config_min_ratio(config_with_profiles) == decimal.Decimal("0.15") # 2. With direct config value only config_with_direct = { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 3.3 } # Should pick 3.3% from direct config assert mode._get_config_min_ratio(config_with_direct) == decimal.Decimal("0.033") # 3. With neither, should fall back to mode.rebalance_trigger_min_ratio mode.rebalance_trigger_min_ratio = decimal.Decimal("0.123") config_empty = {} assert mode._get_config_min_ratio(config_empty) == decimal.Decimal("0.123") # 4. With profiles but no selected profile matches, should fall back to direct config config_profiles_no_match = { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILES: [ { index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_NAME: "profile-1", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_PROFILE_MIN_PERCENT: 7.5, } ], index_trading.IndexTradingModeProducer.SELECTED_REBALANCE_TRIGGER_PROFILE: "profile-x", index_trading.IndexTradingModeProducer.REBALANCE_TRIGGER_MIN_PERCENT: 2.2 } assert mode._get_config_min_ratio(config_profiles_no_match) == decimal.Decimal("0.022") ================================================ FILE: Trading/Mode/market_making_trading_mode/__init__.py ================================================ from .market_making_trading import MarketMakingTradingMode ================================================ FILE: Trading/Mode/market_making_trading_mode/config/MarketMakingTradingMode.json ================================================ { "required_strategies": [], "asks_count": 3, "bids_count": 3, "min_spread": 2, "max_spread": 10, "reference_exchange": "local" } ================================================ FILE: Trading/Mode/market_making_trading_mode/market_making_trading.py ================================================ # pylint: disable=E701 # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import asyncio import collections import dataclasses import decimal import typing import octobot_commons.enums as commons_enums import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.pretty_printer import octobot_tentacles_manager.api import octobot_trading.api as trading_api import octobot_trading.constants as trading_constants import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.enums as trading_enums import octobot_trading.errors as trading_errors import octobot_trading.modes as trading_modes import octobot_trading.personal_data as trading_personal_data import octobot_trading.exchanges as trading_exchanges import octobot_tentacles_manager.configuration as tm_configuration import tentacles.Trading.Mode.market_making_trading_mode.order_book_distribution as order_book_distribution import tentacles.Trading.Mode.market_making_trading_mode.reference_price as reference_price_import @dataclasses.dataclass class OrderData: side: trading_enums.TradeOrderSide = None quantity: decimal.Decimal = trading_constants.ZERO price: decimal.Decimal = trading_constants.ZERO symbol: str = 0 class OrderAction: pass @dataclasses.dataclass class CreateOrderAction(OrderAction): order_data: OrderData @classmethod def from_book_order_data(cls, symbol, order: order_book_distribution.BookOrderData): return cls( OrderData( side=order.side, quantity=order.amount, price=order.price, symbol=symbol, ) ) @dataclasses.dataclass class CancelOrderAction(OrderAction): order: trading_personal_data.Order @dataclasses.dataclass class OrdersUpdatePlan: order_actions: list[OrderAction] = dataclasses.field(default_factory=list) cancelled: bool = False cancellable: bool = True force_cancelled: bool = False processed: asyncio.Event = dataclasses.field(default_factory=asyncio.Event) trigger_source: str = "" def __str__(self): cancel_actions = [a for a in self.order_actions if isinstance(a, CancelOrderAction)] create_actions = [a for a in self.order_actions if isinstance(a, CreateOrderAction)] return ( f"{self.__class__.__name__} of {len(self.order_actions)} {OrderAction.__name__} [{len(cancel_actions)} " f"{CancelOrderAction.__name__} & {len(create_actions)} {CreateOrderAction.__name__}], " f"cancelled: {self.cancelled} cancellable: {self.cancellable} " f"[trigger_source: {self.trigger_source}]" ) class SkippedAction(Exception): pass class MarketMakingTradingMode(trading_modes.AbstractTradingMode): REQUIRE_TRADES_HISTORY = False # set True when this trading mode needs the trade history to operate SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION = False # set True when self._optimize_initial_portfolio is implemented SUPPORTS_HEALTH_CHECK = False # set True when self.health_check is implemented MIN_SPREAD = "min_spread" MAX_SPREAD = "max_spread" BIDS_COUNT = "bids_count" ASKS_COUNT = "asks_count" REFERENCE_EXCHANGE = "reference_exchange" LOCAL_EXCHANGE_PRICE = "local" MIN_SPREAD_DESC = "Min spread %: Percentage of the current price to use as bid-ask spread." MAX_SPREAD_DESC = "Max spread %: Percentage of the current price to use to define the target order book depth." BIDS_COUNT_DECS = "Bids count: How many buy orders to create in the order book." ASKS_COUNT_DECS = "Asks count: How many sell orders to create in the order book." REFERENCE_EXCHANGE_DESC = ( f"Reference exchange. Used as the price source to create the order book's orders from. " f"This exchange need to have a trading market for the selected traded pair. Example: \"binance\". " f"Use \"{LOCAL_EXCHANGE_PRICE}\" to use the current exchange price." ) def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.UI.user_input( self.MIN_SPREAD, commons_enums.UserInputTypes.FLOAT, 2, inputs, min_val=0, max_val=int(order_book_distribution.TARGET_CUMULATED_VOLUME_PERCENT * 2) , other_schema_values={"exclusiveMinimum": True, "exclusiveMaximum": True}, title=self.MIN_SPREAD_DESC ) self.UI.user_input( self.MAX_SPREAD, commons_enums.UserInputTypes.FLOAT, 6, inputs, min_val=0, max_val=200, other_schema_values={"exclusiveMinimum": True, "exclusiveMaximum": True}, title=self.MAX_SPREAD_DESC, ) self.UI.user_input( self.BIDS_COUNT, commons_enums.UserInputTypes.INT, 5, inputs, min_val=0, max_val=order_book_distribution.MAX_HANDLED_BIDS_ORDERS, other_schema_values={"exclusiveMinimum": True, "exclusiveMaximum": False}, title=self.BIDS_COUNT_DECS, ) self.UI.user_input( self.ASKS_COUNT, commons_enums.UserInputTypes.INT, 5, inputs, min_val=0, max_val=order_book_distribution.MAX_HANDLED_ASKS_ORDERS, other_schema_values={"exclusiveMinimum": True, "exclusiveMaximum": False}, title=self.ASKS_COUNT_DECS, ) self.UI.user_input( self.REFERENCE_EXCHANGE, commons_enums.UserInputTypes.TEXT, "binance", inputs, other_schema_values={"inputAttributes": {"placeholder": "binance"}}, title=self.REFERENCE_EXCHANGE_DESC ) def get_current_state(self) -> (str, float): order = self.producers[0].get_market_making_orders() if self.producers else [] bids = [o for o in order if o.side == trading_enums.TradeOrderSide.SELL] asks = [o for o in order if o.side == trading_enums.TradeOrderSide.BUY] if len(bids) > len(asks): state = trading_enums.EvaluatorStates.LONG elif len(bids) < len(asks): state = trading_enums.EvaluatorStates.SHORT else: state = trading_enums.EvaluatorStates.NEUTRAL bid_volume = sum(o.total_cost for o in bids) ask_volume = sum(o.origin_quantity for o in asks) symbol = symbol_util.parse_symbol(self.symbol) return ( state.name, f"{bid_volume} {symbol.quote} in {len(bids)} bids, {ask_volume} {symbol.base} in {len(asks)} asks" ) def get_mode_producer_classes(self) -> list: return [MarketMakingTradingModeProducer] def get_mode_consumer_classes(self) -> list: return [MarketMakingTradingModeConsumer] @classmethod async def get_forced_updater_channels( cls, exchange_manager: trading_exchanges.ExchangeManager, tentacles_setup_config: tm_configuration.TentaclesSetupConfiguration, trading_config: typing.Optional[dict] ) -> set[trading_exchanges.ChannelSpecs]: return set([ trading_exchanges.ChannelSpecs( channel=trading_constants.TICKER_CHANNEL, ), trading_exchanges.ChannelSpecs( channel=trading_constants.TRADES_CHANNEL, ) ]) @classmethod def get_is_trading_on_exchange(cls, exchange_name, tentacles_setup_config) -> bool: """ returns True if exchange_name is trading exchange or the hedging exchange """ return cls.has_trading_exchange_configuration( exchange_name, octobot_tentacles_manager.api.get_tentacle_config(tentacles_setup_config, cls) ) @classmethod def get_is_using_trading_mode_on_exchange(cls, exchange_name, tentacles_setup_config) -> bool: """ returns True if exchange_name is a trading exchange that is not the hedging exchange """ return cls.has_trading_exchange_configuration( exchange_name, octobot_tentacles_manager.api.get_tentacle_config(tentacles_setup_config, cls) ) @classmethod def has_trading_exchange_configuration(cls, exchange_name, tentacle_config: dict): pairs_settings_for_exchange = cls.get_pair_settings_for_exchange(exchange_name, tentacle_config) # trade on this exchange if there is at least a pair config for this exchange return bool(pairs_settings_for_exchange) @classmethod def get_pair_settings_for_exchange(cls, target_exchange_name, tentacle_config) -> list: if cls.is_exchange_compatible_pair_setting(tentacle_config, target_exchange_name): return [tentacle_config] return [] def get_pair_settings(self) -> list: if self.is_exchange_compatible_pair_setting(self.trading_config, self.exchange_manager.exchange_name): return [self.trading_config] return [] @classmethod def is_exchange_compatible_pair_setting(cls, trading_config: dict, target_exchange_name: str) -> bool: return ( trading_config[cls.REFERENCE_EXCHANGE] != target_exchange_name ) @classmethod def get_is_symbol_wildcard(cls) -> bool: return False @staticmethod def is_backtestable(): return False @classmethod def is_ignoring_cancelled_orders_trades(cls) -> bool: return True async def create_consumers(self) -> list: consumers = await super().create_consumers() # order consumer: filter by symbol not be triggered only on this symbol's orders order_consumer = await exchanges_channel.get_chan(trading_personal_data.OrdersChannel.get_name(), self.exchange_manager.id).new_consumer( self._order_notification_callback, symbol=self.symbol ) return consumers + [order_consumer] async def _order_notification_callback( self, exchange, exchange_id, cryptocurrency, symbol, order, update_type, is_from_bot ): if ( order[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == trading_enums.OrderStatus.FILLED.value and order[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] in ( trading_enums.TradeOrderType.LIMIT.value ) ): await self.producers[0].order_filled_callback(order) def set_default_config(self): raise RuntimeError(f"Impossible to start {self.get_name()} without a valid configuration file.") @classmethod def get_order_book_distribution(cls, pair_config: dict) -> order_book_distribution.OrderBookDistribution: try: min_spread = decimal.Decimal(str(pair_config[cls.MIN_SPREAD] / 100)) max_spread = decimal.Decimal(str(pair_config[cls.MAX_SPREAD] / 100)) bids_count = int(pair_config[cls.BIDS_COUNT]) asks_count = int(pair_config[cls.ASKS_COUNT]) return order_book_distribution.OrderBookDistribution( bids_count, asks_count, min_spread, max_spread, ) except TypeError as err: raise ValueError(f"Invalid config value: {err}") from err class MarketMakingTradingModeConsumer(trading_modes.AbstractTradingModeConsumer): ORDER_ACTIONS_PLAN_KEY = "order_actions_plan" CURRENT_PRICE_KEY = "current_price" SYMBOL_MARKET_KEY = "symbol_market" def skip_portfolio_available_check_before_creating_orders(self) -> bool: """ When returning true, will skip portfolio available funds check before calling self.create_new_orders(). Override if necessary """ # will cancel orders and free funds if necessary return True async def create_new_orders(self, symbol, final_note, state, **kwargs): # use dict default getter: can't afford missing data data = kwargs[self.CREATE_ORDER_DATA_PARAM] order_actions_plan: OrdersUpdatePlan = data[self.ORDER_ACTIONS_PLAN_KEY] current_price = data[self.CURRENT_PRICE_KEY] symbol_market = data[self.SYMBOL_MARKET_KEY] try: if order_actions_plan.cancelled: # any plan can be cancelled if it did not start processing self.logger.info(f"Cancelling {str(order_actions_plan)} action plan processing: plan did not start") return [] else: self.logger.info(f"Starting {str(order_actions_plan)} action plan processing") return await self._process_plan(order_actions_plan, current_price, symbol_market) finally: order_actions_plan.processed.set() async def _process_plan(self, order_actions_plan: OrdersUpdatePlan, current_price, symbol_market): created_orders = [] cancelled_orders = [] processed_actions = {} skipped_actions = {} scheduled_actions = collections.deque(order_actions_plan.order_actions) while scheduled_actions: action = scheduled_actions.popleft() try: if ( (order_actions_plan.cancelled and order_actions_plan.cancellable) or order_actions_plan.force_cancelled ): actions_class = action.__class__.__name__ self.logger.debug( f"{self.trading_mode.symbol} {self.exchange_manager.exchange_name} " f"order actions cancelled, skipping {actions_class} action." ) if actions_class not in skipped_actions: skipped_actions[actions_class] = 1 else: skipped_actions[actions_class] += 1 else: await self._process_action( action, current_price, symbol_market, processed_actions, created_orders, cancelled_orders ) except Exception as err: self.logger.exception(err, True, f"Error when processing {action}: {err}") self._log_actions_report( order_actions_plan, processed_actions, skipped_actions, created_orders, cancelled_orders ) return created_orders def _log_actions_report( self, order_actions_plan, processed_actions, skipped_actions, created_orders, cancelled_orders ): skipped_actions_str = f", skipped actions: {skipped_actions}" if skipped_actions else '' create_actions = processed_actions.get(CreateOrderAction.__name__, 0) cancel_actions = processed_actions.get(CancelOrderAction.__name__, 0) self.logger.info( f"Completed {self.trading_mode.symbol} [{self.exchange_manager.exchange_name}] " f"{cancel_actions + create_actions}/{len(order_actions_plan.order_actions)} " f"order actions: {len(created_orders)}/{create_actions} created orders, " f"{len(cancelled_orders)}/{cancel_actions} cancelled orders{skipped_actions_str}." ) async def _process_action( self, action: OrderAction, current_price, symbol_market, processed_actions: dict, created_orders: list, cancelled_orders: list, **kwargs, ): actions_class = action.__class__.__name__ if isinstance(action, CreateOrderAction): created_orders += ( await self.create_order(action.order_data, current_price, symbol_market, **kwargs) ) elif isinstance(action, CancelOrderAction): if action.order.is_open(): try: await self.trading_mode.cancel_order(action.order) cancelled_orders.append(action.order.order_id) except trading_errors.UnexpectedExchangeSideOrderStateError as err: self.logger.warning(f"Skipped order cancel: {err}, order: {str(action.order)}") except trading_errors.OrderCancelError as err: self.logger.warning( f"Error when cancelling order, considering order as closed. Error: {err}, " f"order: {str(action.order)}" ) else: self.logger.info( f"{self.trading_mode.symbol} {self.exchange_manager.exchange_name} ignored cancel order " f"action: Order is not open anymore. Order: {str(action.order)}" ) else: raise NotImplementedError( f"{self.trading_mode.symbol} {self.exchange_manager.exchange_name} {action} is not supported" ) if actions_class not in processed_actions: processed_actions[actions_class] = 1 else: processed_actions[actions_class] += 1 async def create_order(self, order_data, current_price, symbol_market, **kwargs): created_order = None currency, market = symbol_util.parse_symbol(order_data.symbol).base_and_quote() try: base_available = trading_api.get_portfolio_currency(self.exchange_manager, currency).available quote_available = trading_api.get_portfolio_currency(self.exchange_manager, market).available selling = order_data.side == trading_enums.TradeOrderSide.SELL quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, order_data.symbol, trading_enums.TraderOrderType.SELL_LIMIT if selling else trading_enums.TraderOrderType.BUY_LIMIT, order_data.quantity, order_data.price, order_data.side, ) orders_quantity_and_price = trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, order_data.price, symbol_market ) if orders_quantity_and_price: if len(orders_quantity_and_price) > 1: self.logger.error( f"Orders to create are too large and have to be split. This is not supported. " f"Only creating the 1s order." ) orders_quantity_and_price = orders_quantity_and_price[:1] for order_quantity, order_price in orders_quantity_and_price: order_desc = ( f"{order_data.symbol} {order_data.side.value} [{self.exchange_manager.exchange_name}] order " f"creation of {order_quantity} at {float(order_price)}" ) should_skip, skip_message = self._should_skip( selling, base_available, quote_available, order_quantity, order_price, order_desc, currency, market, **kwargs ) if should_skip: self.logger.warning(f"Skipping {skip_message}") return [] order_type = trading_enums.TraderOrderType.SELL_LIMIT if selling \ else trading_enums.TraderOrderType.BUY_LIMIT current_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=order_data.symbol, current_price=current_price, quantity=order_quantity, price=order_price, ) # disable instant fill to avoid looping order fill in simulator current_order.allow_instant_fill = False created_order = await self.trading_mode.create_order(current_order) if not created_order: self.logger.warning( f"No order created for {order_data} (quantity: {quantity}): " f"incompatible with exchange minimum rules. " f"Limits: {symbol_market[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS.value]}" ) except (SkippedAction, trading_errors.MissingFunds): raise except Exception as e: self.logger.error(f"Failed to create order : {e}. Order: {order_data}") return [] return [] if created_order is None else [created_order] def _should_skip( self, selling, base_available, quote_available, order_quantity, order_price, order_desc, currency, market, **kwargs ): skip_message = "" if selling: if base_available < order_quantity: skip_message = ( f"{order_desc}: " f"not enough {currency}: available: {base_available}, required: {order_quantity}" ) elif quote_available < order_quantity * order_price: skip_message = ( f"Skipping {order_desc}: not enough {market}: available: {quote_available}, " f"required: {order_quantity * order_price}" ) return bool(skip_message), skip_message class MarketMakingTradingModeProducer(trading_modes.AbstractTradingModeProducer): PRICE_FETCHING_TIMEOUT = 60 ORDER_ACTION_TIMEOUT = 20 INIT_RETRY_TIMER = 5 REFERENCE_PRICE_INIT_DELAY = 60 # allow 60s before logging missing reference prices as error ORDERS_DESC = "market making" def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) # no state for this evaluator: always neutral self.state = trading_enums.EvaluatorStates.NEUTRAL # config self.symbol: str = trading_mode.symbol self.order_book_distribution: order_book_distribution.OrderBookDistribution = None self.reference_price = reference_price_import.PriceSource self.replace_whole_book_distance_threshold: float = 0.5 self.symbol_trading_config: dict = None self.healthy = False self.subscribed_channel_specs_by_exchange_id: dict[str, set[trading_exchanges.ChannelSpecs]] = {} self.is_first_execution: bool = True self._started_at: float = 0 self._last_error_at: float = 0 self.latest_actions_plan: OrdersUpdatePlan = None self.last_target_buy_orders_count: int = 0 self.last_target_sell_orders_count: int = 0 try: self._load_symbol_trading_config() except KeyError as e: error_message = f"Impossible to start {self.ORDERS_DESC} orders for {self.symbol}: missing " \ f"configuration in trading mode config file. " self.logger.exception(e, True, error_message) return if self.symbol_trading_config is None: return self.read_config() self.logger.debug(f"Loaded healthy config for {self.symbol}") self.healthy = True def _load_symbol_trading_config(self) -> bool: self.symbol_trading_config = self.trading_mode.get_pair_settings()[0] return True def read_config(self): self.order_book_distribution = self.trading_mode.get_order_book_distribution(self.symbol_trading_config) self.reference_price = reference_price_import.PriceSource( self.symbol_trading_config[self.trading_mode.REFERENCE_EXCHANGE], self.symbol ) if len(self.exchange_manager.exchange_config.traded_symbols) > 1: error = ( f"Multiple trading pair is not supported on {self.trading_mode.get_name()}. " f"Please select only one trading pair in configuration." ) asyncio.create_task( self.sent_once_critical_notification( "Configuration issue", error ) ) raise ValueError(error) enabled_exchanges = trading_exchanges.get_enabled_exchanges(self.exchange_manager.config) if ( self.reference_price.exchange != self.trading_mode.LOCAL_EXCHANGE_PRICE and self.reference_price.exchange not in enabled_exchanges ): error = ( f"Reference exchange is missing from configuration. Please add {self.reference_price.exchange} to " f"configured exchanges or use another reference exchange." ) asyncio.create_task( self.sent_once_critical_notification( "Configuration issue", error ) ) raise ValueError(error) async def start(self) -> None: await super().start() if self.healthy: self.logger.debug(f"Initializing orders creation") await self._ensure_market_making_orders_and_reschedule() async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): # nothing to do: this is not a strategy related trading mode pass def _schedule_order_refresh(self): # schedule order creation / health check asyncio.create_task(self._ensure_market_making_orders_and_reschedule()) async def _ensure_market_making_orders_and_reschedule(self): if self.should_stop: return can_create_orders = ( not trading_api.get_is_backtesting(self.exchange_manager) or trading_api.is_mark_price_initialized(self.exchange_manager, symbol=self.symbol) ) and ( trading_api.get_portfolio(self.exchange_manager) != {} or trading_api.is_trader_simulated(self.exchange_manager) ) if can_create_orders: try: if not await self._ensure_market_making_orders( "initial trigger" if self.is_first_execution else "periodic trigger" ): can_create_orders = False except asyncio.TimeoutError: can_create_orders = False if not self.should_stop: await self._reschedule_if_necessary(can_create_orders) async def _reschedule_if_necessary(self, can_create_orders: bool): if not can_create_orders: self.logger.info( f"Can't yet create initialize orders for {self.symbol}, retrying in {self.INIT_RETRY_TIMER} seconds" ) # avoid spamming retries when price is not available self.scheduled_health_check = asyncio.get_event_loop().call_later( self.INIT_RETRY_TIMER, self._schedule_order_refresh ) async def _ensure_market_making_orders(self, trigger_source: str): # can be called: # - on initialization # - when price moves beyond spread # - when orders are filled _, _, _, current_price, symbol_market = await trading_personal_data.get_pre_order_data( self.exchange_manager, symbol=self.symbol, timeout=self.PRICE_FETCHING_TIMEOUT ) return await self.create_state(current_price, symbol_market, trigger_source, False) async def create_state(self, current_price, symbol_market, trigger_source: str, force_full_refresh: bool): if current_price is not None: async with self.trading_mode_trigger(skip_health_check=True): if self.exchange_manager.trader.is_enabled: try: if await self._handle_market_making_orders( current_price, symbol_market, trigger_source, force_full_refresh ): self.is_first_execution = False self._started_at = self.exchange_manager.exchange.get_exchange_current_time() return True except ValueError as err: if self._last_error_at <= self._started_at: # only log full exception every 1st time it occurs then use warnings to avoid flooding # when on websockets self.logger.exception( err, True, f"Unexpected error when starting {self.symbol} trading mode: {err}" ) else: self.logger.warning(f"Skipped {self.symbol} orders update: {err}") self._last_error_at = self.exchange_manager.exchange.get_exchange_current_time() if "Missing volume" not in str(err): # config error: should not happen, in this case, return true to skip auto reschedule await self.sent_once_critical_notification( "Configuration issue", f"Impossible to start {self.symbol} market making " f"on {self.exchange_manager.exchange_name}: {err}" ) return True return False def _is_previous_plan_still_processing(self) -> bool: return self.latest_actions_plan is not None and not self.latest_actions_plan.processed.is_set() async def _handle_market_making_orders( self, current_price, symbol_market, trigger_source: str, force_full_refresh: bool ): # 1. get price from external source reference_price = await self._get_reference_price() if not reference_price: method = self.logger.info if self.is_first_execution else self.logger.error method( f"Skipped trigger: can't compute {self.symbol} reference price for" f" {self.exchange_manager.exchange_name}: {reference_price=}" ) return False daily_base_volume, daily_quote_volume = self._get_daily_volume(reference_price) if not all(v and not v.is_nan() for v in (daily_base_volume, daily_quote_volume)): method = self.logger.info if self.is_first_execution else self.logger.error method( f"Skipped trigger: can't compute {self.symbol} daily volume for" f" {self.exchange_manager.exchange_name}: {daily_base_volume=} {daily_quote_volume=}" ) return False base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() self.logger.info( f"Trigger for {self.symbol} on {self.exchange_manager.exchange_name}. Ref price: {float(reference_price)} " f"daily {base} vol: {octobot_commons.pretty_printer.get_min_string_from_number(daily_base_volume)} " f"daily {quote} vol: {octobot_commons.pretty_printer.get_min_string_from_number(daily_quote_volume)} " f"[trigger source: {trigger_source}]" ) open_orders = self.get_market_making_orders() require_data_refresh = False if self._is_previous_plan_still_processing(): # if previous plan is still processing but being cancelled: skip call (another one is waiting for cancel) skip_exec = self.latest_actions_plan.cancelled # if previous plan is still processing and not being cancelled: check if cancel is required if not self.latest_actions_plan.cancelled: # only cancel latest plan if outdated and still processing, otherwise ignore signal previous_plan_orders = [ action.order_data for action in self.latest_actions_plan.order_actions if isinstance(action, CreateOrderAction) ] previous_plan_cancelled_orders = [ action.order for action in self.latest_actions_plan.order_actions if isinstance(action, CancelOrderAction) ] remaining_open_orders = [ order for order in open_orders if order not in previous_plan_cancelled_orders ] if self._get_orders_to_cancel(previous_plan_orders + remaining_open_orders, reference_price): # cancel previous plan self.latest_actions_plan.cancelled = True if self.latest_actions_plan.cancellable: self.logger.debug( f"Cancelling previous plan after {reference_price} {self.symbol} price update for " f"{self.exchange_manager.exchange_name}: orders are outdated " f"[trigger source: {trigger_source}]." ) else: self.logger.debug( f"Waiting for non-cancellable action plan to complete: {reference_price} {self.symbol} " f"price update for {self.exchange_manager.exchange_name}: orders are outdated " f"[trigger source: {trigger_source}]." ) try: waiting_plan = self.latest_actions_plan await asyncio.wait_for(waiting_plan.processed.wait(), self.ORDER_ACTION_TIMEOUT) if ( self.latest_actions_plan is not waiting_plan and not self.latest_actions_plan.processed.is_set() ): # plan just changed, skip this update self.logger.debug( f"Skip {self.symbol} {self.exchange_manager.exchange_name} plan execution: a new" f"plan is already being executed [trigger source: {trigger_source}]" ) skip_exec = True else: self.logger.debug( f"Continuing {reference_price} {self.symbol} after latest action plan cancel " f"[trigger source: {trigger_source}]" ) except asyncio.TimeoutError: # don't continue, next refresh will take care of it self.logger.debug( f"Timeout when waiting for {reference_price} {self.symbol} latest action plan: " f"{str(self.latest_actions_plan)} [trigger source: {trigger_source}]" ) skip_exec = True finally: require_data_refresh = True else: skip_exec = True if skip_exec: # let previous plan execute, ignore signal self.logger.debug( f"Ignored {reference_price} {self.symbol} price update for {self.exchange_manager.exchange_name} " f"while previous orders plan is still processing [trigger source: {trigger_source}]" ) return False if require_data_refresh: # update reference price in case it changed reference_price = await self._get_reference_price() if not reference_price: self.logger.error( f"Can't compute reference price for {self.exchange_manager.exchange_name}: after waiting " f"for previous plan processing: {reference_price=}" ) return False # update open orders in case it changed after waiting open_orders = self.get_market_making_orders() sorted_orders = self._sort_orders(open_orders) available_base, available_quote = self._get_available_funds() theoretically_available_base, theoretically_available_quote = ( self._get_all_theoretically_available_funds(open_orders) ) self.logger.debug( f"MM available {self.symbol} funds: {base}: {float(available_base)} {quote}: {float(available_quote)}" ) # 2. cancel outdated orders outdated_orders = self._get_orders_to_cancel(sorted_orders, reference_price) if outdated_orders: self.logger.info( f"{len(outdated_orders)} outdated orders for {self.symbol} on {self.exchange_manager.exchange_name} (trigger_source: {trigger_source}): " f"{[str(o) for o in outdated_orders]}" ) # get ideal distribution ideal_distribution = self.order_book_distribution.compute_distribution( reference_price, daily_base_volume, daily_quote_volume, symbol_market, available_base=theoretically_available_base, available_quote=theoretically_available_quote, ) # update last target orders count self.last_target_sell_orders_count = len(ideal_distribution.asks) self.last_target_buy_orders_count = len(ideal_distribution.bids) cancelled_orders = created_orders = [] missing_all_orders_sides = [] try: if force_full_refresh: raise order_book_distribution.FullBookRebalanceRequired("Forced full refresh") book_orders_after_swaps, cancelled_orders, created_orders = ( self._get_swapped_book_orders( sorted_orders, outdated_orders, available_base, available_quote, reference_price, ideal_distribution, daily_base_volume, daily_quote_volume ) ) is_spread_according_to_config = ideal_distribution.is_spread_according_to_config( book_orders_after_swaps, open_orders ) # Compute distance from distribution. # Warning: filled orders result in more funds being available, which can create full order book rebalance as # distance from ideal would become too large. This can happen when the order book depth is far from the required # value. distance_from_ideal_after_swaps = ideal_distribution.get_shape_distance_from( book_orders_after_swaps, theoretically_available_base, theoretically_available_quote, reference_price, daily_base_volume, daily_quote_volume, trigger_source ) can_just_replace_a_few_orders = is_spread_according_to_config and ( distance_from_ideal_after_swaps < self.replace_whole_book_distance_threshold ) except order_book_distribution.MissingOrderException as err: orders = [] if isinstance(err, order_book_distribution.MissingAllOrders): orders = ideal_distribution.asks + ideal_distribution.bids missing_all_orders_sides.extend((trading_enums.TradeOrderSide.BUY, trading_enums.TradeOrderSide.SELL)) elif isinstance(err, order_book_distribution.MissingAllAsks): orders = ideal_distribution.asks missing_all_orders_sides.append(trading_enums.TradeOrderSide.SELL) elif isinstance(err, order_book_distribution.MissingAllBids): orders = ideal_distribution.bids missing_all_orders_sides.append(trading_enums.TradeOrderSide.BUY) # no open order but can create some if the total amount of orders is > 0 # => means we have the funds to create those orders, we should create them self.logger.info( f"Missing orders on {2 if isinstance(err, order_book_distribution.MissingAllOrders) else 1} " f"side: {err.__class__.__name__}" ) is_spread_according_to_config = False distance_from_ideal_after_swaps = trading_constants.ONE can_just_replace_a_few_orders = not sum(o.amount for o in orders) > trading_constants.ZERO except order_book_distribution.FullBookRebalanceRequired as err: cancelled_orders = created_orders = [] is_spread_according_to_config = True distance_from_ideal_after_swaps = trading_constants.ONE can_just_replace_a_few_orders = False self.logger.info(f"Scheduling full order book refresh: {err}") if can_just_replace_a_few_orders: if sum(o.amount for o in created_orders) <= trading_constants.ZERO: # no order can be created (not enough funds) created_orders = [] await self._send_missing_funds_critical_notification(missing_all_orders_sides) self.logger.info( f"No order to create (order amounts is 0), leaving book as is [trigger source: {trigger_source}]" ) elif not self._can_create_all_order(created_orders, symbol_market): # if orders can't be created (because too small for example), then recreate the whole book to # create all orders self.logger.warning( f"Missing funds: few orders can't be created, resizing book instead [trigger source: {trigger_source}]" ) can_just_replace_a_few_orders = False if can_just_replace_a_few_orders: # A. Threshold is not met if not (outdated_orders or cancelled_orders or created_orders): # A.1: no order to replace, nothing to do self.logger.debug( f"{self.symbol} {self.exchange_manager.exchange_name} orders are up to date " f"[trigger source: {trigger_source}]" ) return True else: # A.2: orders are just created to fill the order book self.logger.info( f"Replacing {self.symbol} {self.exchange_manager.exchange_name} missing orders: " f"{len(outdated_orders)} outdated orders, {len(cancelled_orders)} cancelled_orders, " f"{len(created_orders)} created_orders spread conform to config: {is_spread_according_to_config} " f"[trigger source: {trigger_source}]" ) order_actions_plan = self._get_create_missing_orders_plan( outdated_orders, cancelled_orders, created_orders ) else: # B. A full order book replacement is required if new orders can fix the issue if len(missing_all_orders_sides) != 1 or ( len(missing_all_orders_sides) == 1 and ideal_distribution.can_create_at_least_one_order( missing_all_orders_sides, symbol_market ) ): # B.1: Orders should and can be replaced: replaced them one by one self.logger.info( f"Re-creating the whole {self.symbol} {self.exchange_manager.exchange_name} order book: book is too " f"different from configuration (distance: {distance_from_ideal_after_swaps}) " f"[trigger source: {trigger_source}]" ) else: # B.2: Orders can't be replaced: they are not following exchange requirements: skip them for side in missing_all_orders_sides: # filter out orders created_orders = [ order for order in created_orders if order.side != side ] # remove filtered orders from ideal_distribution in case an action plan gets created if side == trading_enums.TradeOrderSide.BUY: ideal_distribution.bids.clear() else: ideal_distribution.asks.clear() skip_iteration = not created_orders and not cancelled_orders error_details = await self._send_missing_funds_critical_notification(missing_all_orders_sides) self.logger.warning(f"{'Skipped iteration: ' if skip_iteration else ''}{error_details}") if skip_iteration: # all the orders to create can't actually be created and there is nothing to replace: nothing to do return True # create action plan from ideal distribution order_actions_plan = self._get_replace_full_book_plan( outdated_orders, sorted_orders, ideal_distribution ) order_actions_plan.trigger_source = trigger_source # 4. push orders creation and cancel plan await self._schedule_order_actions(order_actions_plan, current_price, symbol_market) return True async def _send_missing_funds_critical_notification(self, missing_all_orders_sides) -> str: base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() required_funds = [] for side in missing_all_orders_sides: if side == trading_enums.TradeOrderSide.BUY: required_funds.append(quote) else: required_funds.append(base) if required_funds: missing_funds = ' and '.join(required_funds) error_details = ( f"Impossible to create {self.symbol} {' and '.join([s.value for s in missing_all_orders_sides])} " f"orders: missing available funds to comply with {self.exchange_manager.exchange_name} " f"minimal order size rules. Additional {missing_funds} required." ) await self.sent_once_critical_notification(f"More {missing_funds} required", error_details) return error_details return "" def _can_create_all_order(self, created_orders: list[order_book_distribution.BookOrderData], symbol_market): for order in created_orders: if not trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( order.amount, order.price, symbol_market ): self.logger.info(f"{order} can't be created: {order.amount=} or {order.price=} are too small") return False return True def _get_swapped_book_orders( self, sorted_orders, outdated_orders, available_base, available_quote, reference_price, ideal_distribution, daily_base_volume, daily_quote_volume ): remaining_orders = [o for o in sorted_orders if o not in outdated_orders] remaining_orders_data = [ order_book_distribution.BookOrderData( o.origin_price, o.origin_quantity, o.side, ) for o in remaining_orders ] remaining_order_prices = [o.price for o in remaining_orders_data] updated_book_orders: list[order_book_distribution.BookOrderData] = ( ideal_distribution.infer_full_order_data_after_swaps( remaining_orders_data, outdated_orders, available_base, available_quote, reference_price, daily_base_volume, daily_quote_volume ) ) created_orders = [ order for order in updated_book_orders if order.price not in remaining_order_prices ] updated_book_order_prices = [o.price for o in updated_book_orders] cancelled_orders = [ order for order in remaining_orders if order.origin_price not in updated_book_order_prices ] return updated_book_orders, cancelled_orders, created_orders def _get_create_missing_orders_plan( self, outdated_orders: list[trading_personal_data.Order], cancelled_orders: list[trading_personal_data.Order], created_orders: list[order_book_distribution.BookOrderData] ) -> OrdersUpdatePlan: # 1. cancel outdated orders orders_actions: list[OrderAction] = [CancelOrderAction(order) for order in outdated_orders] # 2. replace orders orders_actions += self._get_alternated_cancel_and_create_order_actions( cancelled_orders, created_orders, False ) return OrdersUpdatePlan(orders_actions) def _get_replace_full_book_plan( self, outdated_orders: list[trading_personal_data.Order], existing_orders: list[trading_personal_data.Order], ideal_distribution: order_book_distribution.OrderBookDistribution ) -> OrdersUpdatePlan: # 1. cancel outdated orders orders_actions: list[OrderAction] = [CancelOrderAction(order) for order in outdated_orders] cancelled_orders = [o for o in existing_orders if o not in outdated_orders] # 2. recreate orders orders_actions += self._get_alternated_cancel_and_create_order_actions( cancelled_orders, ideal_distribution.asks + ideal_distribution.bids, True ) return OrdersUpdatePlan(orders_actions, cancellable=False) def _get_alternated_cancel_and_create_order_actions( self, cancelled_orders: list[trading_personal_data.Order], created_orders: list[order_book_distribution.BookOrderData], cancel_closer_orders_first_from_second_cancel: bool, ): orders_actions: list[OrderAction] = [] cancelled_buy_orders, created_buy_orders, cancelled_sell_orders, created_sell_orders = \ self._get_prioritized_orders( cancelled_orders, created_orders, cancel_closer_orders_first_from_second_cancel ) # alternate between cancel and create to "move" orders to their new price for i in range(max( len(cancelled_buy_orders), len(created_buy_orders), len(cancelled_sell_orders), len(created_sell_orders) )): if i < len(cancelled_buy_orders): orders_actions.append(CancelOrderAction(cancelled_buy_orders[i])) if i < len(cancelled_sell_orders): orders_actions.append(CancelOrderAction(cancelled_sell_orders[i])) if i < len(created_buy_orders): orders_actions.append(CreateOrderAction.from_book_order_data(self.symbol, created_buy_orders[i])) if i < len(created_sell_orders): orders_actions.append(CreateOrderAction.from_book_order_data(self.symbol, created_sell_orders[i])) return orders_actions def _get_prioritized_orders( self, cancelled_orders: list[trading_personal_data.Order], created_orders: list[order_book_distribution.BookOrderData], cancel_closer_orders_first_from_second_cancel: bool, ): # 1st cancelled order is always the furthest from spread. cancelled_buy_orders = sorted( [o for o in cancelled_orders if o.side is trading_enums.TradeOrderSide.BUY], key=lambda o: o.origin_price, # lowest first ) cancelled_sell_orders = sorted( [o for o in cancelled_orders if o.side is trading_enums.TradeOrderSide.SELL], key=lambda o: o.origin_price, reverse=True, # highest first ) if cancel_closer_orders_first_from_second_cancel: # 2nd cancelled order onwards are either start from the spread or from the outer orders if len(cancelled_buy_orders) > 1: cancelled_buy_orders = [cancelled_buy_orders[0]] + list(reversed(cancelled_buy_orders[1:])) if len(cancelled_sell_orders) > 1: cancelled_sell_orders = [cancelled_sell_orders[0]] + list(reversed(cancelled_sell_orders[1:])) created_buy_orders = order_book_distribution.get_sorted_sided_orders( [o for o in created_orders if o.side is trading_enums.TradeOrderSide.BUY], True # highest first ) created_sell_orders = order_book_distribution.get_sorted_sided_orders( [o for o in created_orders if o.side is trading_enums.TradeOrderSide.SELL], True # lowest first ) return ( cancelled_buy_orders, created_buy_orders, cancelled_sell_orders, created_sell_orders ) def _get_orders_to_cancel( self, open_orders: list[typing.Union[trading_personal_data.Order, OrderData]], reference_price: decimal.Decimal ) -> list[trading_personal_data.Order]: return [ order for order in open_orders if self._is_outdated( order.origin_price if isinstance(order, trading_personal_data.Order) else order.price, order.side, reference_price ) ] def _is_outdated( self, order_price: decimal.Decimal, side: trading_enums.TradeOrderSide, reference_price: decimal.Decimal ) -> bool: if side == trading_enums.TradeOrderSide.BUY: return order_price > reference_price return order_price < reference_price def _sort_orders(self, open_orders: list) -> list: """ Sort orders from the closest to the farthest from spread starting with buy orders """ buy_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] sell_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] return ( sorted(buy_orders, key=lambda o: o.origin_price, reverse=True) + sorted(sell_orders, key=lambda o: o.origin_price) ) @classmethod def get_should_cancel_loaded_orders(cls): return False async def _schedule_order_actions(self, order_actions_plan: OrdersUpdatePlan, current_price, symbol_market): self.logger.info( f"Scheduling {self.symbol} {self.exchange_manager.exchange_name} {str(order_actions_plan)} using " f"current price: {current_price}" ) data = { MarketMakingTradingModeConsumer.ORDER_ACTIONS_PLAN_KEY: order_actions_plan, MarketMakingTradingModeConsumer.CURRENT_PRICE_KEY: current_price, MarketMakingTradingModeConsumer.SYMBOL_MARKET_KEY: symbol_market, } self.latest_actions_plan = order_actions_plan await self.submit_trading_evaluation( cryptocurrency=self.trading_mode.cryptocurrency, symbol=self.trading_mode.symbol, time_frame=None, state=trading_enums.EvaluatorStates.NEUTRAL, data=data ) def _get_orders_to_create( self, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, available_base: decimal.Decimal, available_quote: decimal.Decimal, symbol_market: dict ) -> list[OrderData]: orders = [] distribution = self.order_book_distribution.compute_distribution( reference_price, daily_base_volume, daily_quote_volume, symbol_market, available_base=available_base, available_quote=available_quote, ) asks = collections.deque( OrderData( trading_enums.TradeOrderSide.SELL, book_order.amount, book_order.price, self.symbol, ) for book_order in distribution.asks ) bids = collections.deque( OrderData( trading_enums.TradeOrderSide.BUY, book_order.amount, book_order.price, self.symbol, ) for book_order in distribution.bids ) self.logger.info( f"{self.symbol} {self.exchange_manager.exchange_name} target market marking orders: " f"{len(bids)} bids & {len(asks)} asks: {bids=} {asks=}" ) # alternate by and sell orders to create book from the inside out while asks and bids: orders.append(asks.pop()) orders.append(bids.pop()) # add remaining orders if any if asks: orders += list(asks) if bids: orders += list(bids) return orders def _get_daily_volume(self, reference_price: decimal.Decimal) -> (decimal.Decimal, decimal.Decimal): symbol_data = self.exchange_manager.exchange_symbols_data.get_exchange_symbol_data( self.symbol, allow_creation=False ) try: return trading_api.get_daily_base_and_quote_volume(symbol_data, reference_price) except ValueError as err: raise ValueError( f"Missing volume for {self.symbol} on {self.exchange_manager.exchange_name}: " f"{err}. {reference_price=}" ) from err def _get_available_funds(self) -> (decimal.Decimal, decimal.Decimal): base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() return ( trading_api.get_portfolio_currency(self.exchange_manager, base).available, trading_api.get_portfolio_currency(self.exchange_manager, quote).available ) def _get_all_theoretically_available_funds(self, open_orders: list) -> (decimal.Decimal, decimal.Decimal): technically_available_base, technically_available_quote = self._get_available_funds() for order in open_orders: # order.filled_quantity is not handled in simulator filled_quantity = trading_constants.ZERO if self.exchange_manager.trader.simulate else order.filled_quantity if order.side == trading_enums.TradeOrderSide.BUY: initial_cost = order.origin_quantity * order.origin_price filled_cost = filled_quantity * order.filled_price technically_available_quote += initial_cost - filled_cost elif order.side == trading_enums.TradeOrderSide.SELL: technically_available_base += order.origin_quantity - filled_quantity return technically_available_base, technically_available_quote def get_market_making_orders(self) -> list[trading_personal_data.Order]: return [ order for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders( symbol=self.symbol ) # exclude market and stop orders if isinstance(order, (trading_personal_data.BuyLimitOrder, trading_personal_data.SellLimitOrder)) ] def _is_missing_open_orders( self, sided_orders: list[trading_personal_data.Order], side: trading_enums.TradeOrderSide ) -> bool: if not sided_orders: # no orders on this side: orders are missing return True if (last_target_orders_count := ( self.last_target_buy_orders_count if side == trading_enums.TradeOrderSide.BUY else self.last_target_sell_orders_count )) and (len(sided_orders) < last_target_orders_count) and not self._is_previous_plan_still_processing(): self.logger.info( f"Missing {last_target_orders_count - len(sided_orders)} {self.symbol} {side.value} " f"orders [{self.exchange_manager.exchange_name}], last target count: {last_target_orders_count}" ) # at least one order is missing compared to the last check return True return False async def on_new_reference_price(self, reference_price: decimal.Decimal) -> bool: trigger = False open_orders = self.get_market_making_orders() buy_orders = [ order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY ] if self._is_missing_open_orders(buy_orders, trading_enums.TradeOrderSide.BUY): trigger = True else: max_buy_price = max(order.origin_price for order in buy_orders) if max_buy_price > reference_price: trigger = True sell_orders = [ order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL ] if self._is_missing_open_orders(sell_orders, trading_enums.TradeOrderSide.SELL): trigger = True else: min_sell_price = min(order.origin_price for order in sell_orders) if min_sell_price < reference_price: trigger = True return trigger async def _on_reference_price_update(self): trigger = False if reference_price := await self._get_reference_price(): trigger = await self.on_new_reference_price(reference_price) if trigger: await self._ensure_market_making_orders(f"reference price update: {float(reference_price)}") async def order_filled_callback(self, order: dict): self.logger.info( f"Triggering {self.symbol} [{self.exchange_manager.exchange_name}] order update an order got filled: " f"{order}" ) await self._ensure_market_making_orders( f"filled {order[trading_enums.ExchangeConstantsOrderColumns.SIDE.value]} order" ) async def _mark_price_callback( self, exchange: str, exchange_id: str, cryptocurrency: str, symbol: str, mark_price ): """ Called on a price update from an exchange that is different from the current one :param exchange: name of the exchange :param exchange_id: id of the exchange :param cryptocurrency: related cryptocurrency :param symbol: related symbol :param mark_price: updated mark price :return: None """ await self._on_reference_price_update() async def _subscribe_to_exchange_mark_price(self, exchange_id: str, exchange_manager): specs = trading_exchanges.ChannelSpecs( trading_constants.MARK_PRICE_CHANNEL, self.trading_mode.symbol, None ) if not self.already_subscribed_to_channel(exchange_id, specs): await exchanges_channel.get_chan(trading_constants.MARK_PRICE_CHANNEL, exchange_id).new_consumer( callback=self._mark_price_callback, symbol=self.trading_mode.symbol ) if exchange_id not in self.subscribed_channel_specs_by_exchange_id: self.subscribed_channel_specs_by_exchange_id[exchange_id] = set() self.subscribed_channel_specs_by_exchange_id[exchange_id].add(specs) self.logger.info( f"{self.trading_mode.get_name()} for {self.trading_mode.symbol} on {self.exchange_name}: " f"{exchange_manager.exchange_name} price data feed." ) def already_subscribed_to_channel(self, exchange_id: str, specs: trading_exchanges.ChannelSpecs) -> bool: return ( exchange_id in self.subscribed_channel_specs_by_exchange_id and specs in self.subscribed_channel_specs_by_exchange_id[exchange_id] ) async def _get_reference_price(self) -> decimal.Decimal: local_exchange_name = self.exchange_manager.exchange_name price = trading_constants.ZERO for exchange_id in trading_api.get_all_exchange_ids_with_same_matrix_id( local_exchange_name, self.exchange_manager.id ): exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) if exchange_manager.trading_modes and exchange_manager is not self.exchange_manager: await self.sent_once_critical_notification( "Configuration issue", f"Multiple simultaneous trading exchanges is not supported on {self.trading_mode.get_name()}" ) other_exchange_key = self.trading_mode.LOCAL_EXCHANGE_PRICE if ( self.trading_mode.LOCAL_EXCHANGE_PRICE == self.reference_price.exchange and local_exchange_name == exchange_manager.exchange_name ) else exchange_manager.exchange_name if other_exchange_key != self.reference_price.exchange: continue if exchange_id not in self.subscribed_exchange_ids: await self._subscribe_to_exchange_mark_price(exchange_id, exchange_manager) try: price, updated = trading_personal_data.get_potentially_outdated_price( exchange_manager, self.reference_price.pair ) if not updated: self.logger.warning( f"{exchange_manager.exchange_name} mark price: {price} is outdated for {self.symbol}. " f"Using it anyway" ) except KeyError: method = self.logger.info if self.is_first_execution else ( self.logger.error if ( self.exchange_manager.exchange.get_exchange_current_time() - self._started_at > self.REFERENCE_PRICE_INIT_DELAY ) else self.logger.warning() ) method( f"No {exchange_manager.exchange_name} exchange symbol data for {self.symbol}, " f"it's probably initializing" ) return price ================================================ FILE: Trading/Mode/market_making_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["MarketMakingTradingMode"], "tentacles-requirements": [] } ================================================ FILE: Trading/Mode/market_making_trading_mode/order_book_distribution.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import copy import dataclasses import decimal import typing import octobot_commons.logging as commons_logging import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.personal_data as trading_personal_data DEFAULT_TOLERATED_BELLOW_DEPTH_RATIO = decimal.Decimal("0.80") DEFAULT_TOLERATED_ABOVE_DEPTH_RATIO = decimal.Decimal("1.50") ALLOWED_MIN_SPREAD_RATIO = decimal.Decimal("0.1") ALLOWED_MAX_SPREAD_RATIO = decimal.Decimal("0.1") TARGET_CUMULATED_VOLUME_PERCENT: decimal.Decimal = decimal.Decimal(3) DAILY_TRADING_VOLUME_PERCENT: decimal.Decimal = decimal.Decimal(2) MAX_HANDLED_BIDS_ORDERS = 5 MAX_HANDLED_ASKS_ORDERS = 5 INCREASING = "increasing_towards_current_price" DECREASING = "decreasing_towards_current_price" # allow up to 10 decimals to avoid floating point precision issues due to percent ratios _MAX_PRECISION = decimal.Decimal("1.0000000000") @dataclasses.dataclass class InferredOrderData: ideal_price: decimal.Decimal ideal_amount_percent: decimal.Decimal current_price: typing.Optional[decimal.Decimal] current_origin_amount: typing.Optional[decimal.Decimal] final_amount: typing.Optional[decimal.Decimal] final_price: typing.Optional[decimal.Decimal] @dataclasses.dataclass class BookOrderData: price: decimal.Decimal amount: decimal.Decimal side: trading_enums.TradeOrderSide def get_base_amount(self) -> decimal.Decimal: return self.amount * self.price if self.side == trading_enums.TradeOrderSide.BUY else self.amount class FullBookRebalanceRequired(Exception): pass class MissingOrderException(Exception): pass class MissingAllBids(MissingOrderException): pass class MissingAllAsks(MissingOrderException): pass class MissingAllOrders(MissingOrderException): pass class OrderBookDistribution: def __init__( self, bids_count: int, asks_count: int, min_spread: decimal.Decimal, max_spread: decimal.Decimal, ): self.min_spread: decimal.Decimal = min_spread self.max_spread: decimal.Decimal = max_spread self.bids_count: int = bids_count self.asks_count: int = asks_count self.bids: list[BookOrderData] = [] self.asks: list[BookOrderData] = [] def get_ideal_total_volume( self, side: trading_enums.TradeOrderSide, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, ) -> decimal.Decimal: orders_count, start_price, end_price, reference_volume, available_funds = self._get_sided_orders_details( side, reference_price, daily_base_volume, daily_quote_volume, None, None, [] ) # order prices are sorted from the inside out of the order book (closest to the price first) order_prices = self._get_order_prices(start_price, end_price, orders_count) return self._get_total_volume_to_use( side, reference_price, reference_volume, order_prices, available_funds, False ) def compute_distribution( self, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, symbol_market: dict, available_base: typing.Optional[decimal.Decimal] = None, available_quote: typing.Optional[decimal.Decimal] = None, ): self.bids = self._get_target_orders( trading_enums.TradeOrderSide.BUY, reference_price, daily_base_volume, daily_quote_volume, available_base, available_quote, symbol_market ) self.asks = self._get_target_orders( trading_enums.TradeOrderSide.SELL, reference_price, daily_base_volume, daily_quote_volume, available_base, available_quote, symbol_market ) return self def get_shape_distance_from( self, orders: list[BookOrderData], available_base: decimal.Decimal, available_quote: decimal.Decimal, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, trigger_source: str, ) -> float: """ Returns a float averaging the distance of each given order relatively to the ideal configured order volumes shape """ bids_difference = self._get_sided_orders_distance_from_ideal( orders, available_quote, reference_price, daily_quote_volume, trading_enums.TradeOrderSide.BUY, trigger_source ) asks_difference = self._get_sided_orders_distance_from_ideal( orders, available_base, reference_price, daily_base_volume, trading_enums.TradeOrderSide.SELL, trigger_source ) return float(bids_difference + asks_difference) / 2 def is_spread_according_to_config(self, orders: list[BookOrderData], open_orders: list[trading_personal_data.Order]): open_buy_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY] open_sell_orders = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL] if not (open_buy_orders and open_sell_orders): # missing all buy or sell orders (or both) if not (open_buy_orders or open_sell_orders): raise MissingAllOrders() if not open_buy_orders: raise MissingAllBids() if not open_sell_orders: raise MissingAllAsks() if not (len(open_buy_orders) == self.bids_count and len(open_sell_orders) == self.asks_count): # missing a few orders, spread can't be checked, consider valid return True buy_orders = get_sorted_sided_orders([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY], True) sell_orders = get_sorted_sided_orders([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL], True) min_spread = (sell_orders[0].price - buy_orders[0].price)/( (sell_orders[0].price + buy_orders[0].price) / decimal.Decimal("2") ) max_spread = (sell_orders[-1].price - buy_orders[-1].price)/( (sell_orders[-1].price + buy_orders[-1].price) / decimal.Decimal("2") ) compliant_spread = ( ( self.min_spread * (trading_constants.ONE - ALLOWED_MIN_SPREAD_RATIO) < min_spread < self.min_spread * (trading_constants.ONE + ALLOWED_MIN_SPREAD_RATIO) ) and ( self.max_spread * (trading_constants.ONE - ALLOWED_MAX_SPREAD_RATIO) < max_spread < self.max_spread * (trading_constants.ONE + ALLOWED_MAX_SPREAD_RATIO) ) ) if not compliant_spread: self.get_logger().warning( f"Spread is beyond configuration: {min_spread=} {self.min_spread=} {max_spread=} {self.max_spread=}" ) return compliant_spread def infer_full_order_data_after_swaps( self, existing_orders: list[BookOrderData], outdated_orders: list[trading_personal_data.Order], available_base: decimal.Decimal, available_quote: decimal.Decimal, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, ): """ return the target updated list of BookOrderData using existing_orders as the current state of the order book and the current configuration """ buy_orders = [o for o in existing_orders if o.side == trading_enums.TradeOrderSide.BUY] sell_orders = [o for o in existing_orders if o.side == trading_enums.TradeOrderSide.SELL] updated_existing_orders = copy.copy(existing_orders) if len(buy_orders) < len(sell_orders): # missing buy orders: create missing buy order based on current sell orders adapted_buy_orders = self._infer_sided_order_data_after_swaps( updated_existing_orders, outdated_orders, available_quote, reference_price, daily_quote_volume, trading_enums.TradeOrderSide.BUY ) updated_existing_orders = [o for o in existing_orders if o.side != trading_enums.TradeOrderSide.BUY] # compute sell orders based on adapted buy orders updated_existing_orders += adapted_buy_orders adapted_sell_orders = self._infer_sided_order_data_after_swaps( updated_existing_orders, outdated_orders, available_base, reference_price, daily_base_volume, trading_enums.TradeOrderSide.SELL ) else: # missing sell orders (or both sides): create missing sell order based on current buy orders adapted_sell_orders = self._infer_sided_order_data_after_swaps( updated_existing_orders, outdated_orders, available_base, reference_price, daily_base_volume, trading_enums.TradeOrderSide.SELL ) updated_existing_orders = [o for o in existing_orders if o.side != trading_enums.TradeOrderSide.SELL] # compute sell orders based on adapted buy orders updated_existing_orders += adapted_sell_orders adapted_buy_orders = self._infer_sided_order_data_after_swaps( updated_existing_orders, outdated_orders, available_quote, reference_price, daily_quote_volume, trading_enums.TradeOrderSide.BUY ) return adapted_buy_orders + adapted_sell_orders def _get_sided_orders_distance_from_ideal( self, orders: list[BookOrderData], available_funds: decimal.Decimal, reference_price: decimal.Decimal, daily_volume: decimal.Decimal, side: trading_enums.TradeOrderSide, trigger_source: str, ): # shape distance is computed using the average % difference from the ideal shape of the book closer_to_further_real_orders = get_sorted_sided_orders( [o for o in orders if o.side == side], True ) ideal_orders_count = self.bids_count if side == trading_enums.TradeOrderSide.BUY else self.asks_count if not closer_to_further_real_orders: if ideal_orders_count > 0: self.get_logger().info( f"0 {side.name} open orders, required: {ideal_orders_count} refresh required " f"[trigger source: {trigger_source}]" ) return 1 return 0 if not self._are_total_order_volumes_compatible_with_config( closer_to_further_real_orders, available_funds, reference_price,daily_volume, side, trigger_source ): return 1 min_amount, max_amount = ( min(closer_to_further_real_orders[0].amount, closer_to_further_real_orders[-1].amount), max(closer_to_further_real_orders[0].amount, closer_to_further_real_orders[-1].amount) ) ideal_prices = self._get_order_prices(decimal.Decimal(0), trading_constants.ONE_HUNDRED, ideal_orders_count) raw_ideal_amounts = self._get_order_volumes(side, trading_constants.ONE_HUNDRED, ideal_prices) min_ideal_amount, max_ideal_amount = min(raw_ideal_amounts), max(raw_ideal_amounts) if max_amount == trading_constants.ZERO or max_ideal_amount == trading_constants.ZERO: # impossible to compute distance self.get_logger().info( f"Incompatible total amounts on {side.name} side: {max_amount=}, {max_ideal_amount=}, refresh required " f"[trigger source: {trigger_source}]" ) return 1 # align amounts between 0 and 100 to be able to compare real_amounts = [ decimal.Decimal(str((o.amount - min_amount) * trading_constants.ONE_HUNDRED / max_amount)) for o in closer_to_further_real_orders ] ideal_amounts = [ (a - min_ideal_amount) * trading_constants.ONE_HUNDRED / max_ideal_amount for a in raw_ideal_amounts ] distances = [] for i, ideal_amount in enumerate(ideal_amounts): try: distances.append(abs(ideal_amount - real_amounts[i]) / trading_constants.ONE_HUNDRED) except IndexError: # missing price distances.append(trading_constants.ZERO) if len(real_amounts) > len(ideal_amounts): # real orders that should not be open distances += [decimal.Decimal(1)] * (len(real_amounts) - len(ideal_amounts)) return (sum(distances) / len(distances)) if distances else 0 def _should_use_artificial_funds( self, ideal_total_volume: decimal.Decimal, total_volume: decimal.Decimal, side: trading_enums.TradeOrderSide, tolerated_bellow_depth_ratio=DEFAULT_TOLERATED_BELLOW_DEPTH_RATIO ) -> bool: return ideal_total_volume * tolerated_bellow_depth_ratio > total_volume def _are_total_order_volumes_compatible_with_config( self, closer_to_further_real_orders: list[BookOrderData], available_funds: decimal.Decimal, reference_price: decimal.Decimal, daily_volume: decimal.Decimal, side: trading_enums.TradeOrderSide, trigger_source: str, tolerated_bellow_depth_ratio = DEFAULT_TOLERATED_BELLOW_DEPTH_RATIO, tolerated_above_depth_ratio = DEFAULT_TOLERATED_ABOVE_DEPTH_RATIO, ) -> bool: order_prices = [o.price for o in closer_to_further_real_orders] ideal_total_volume = self._get_ideal_total_volume_to_use( side, reference_price, daily_volume, order_prices, False ) total_volume = self._get_total_volume_to_use( side, reference_price, daily_volume, order_prices, available_funds, False ) if self._should_use_artificial_funds(ideal_total_volume, total_volume, side): # case 1. not enough funds and compliant config to use ideal volume: check all orders total amount # against available funds (taken into account in total_volume) # case 2. enough funds and non-compliant config to use ideal volume: check all orders total amount # against target config (taken into account in total_volume) # case 3. both cases 1. and 2. => same outcome theoretical_used_funds = total_volume current_used_funds = sum( order.get_base_amount() for order in closer_to_further_real_orders ) required_source = "available funds or config" else: # case 4: enough funds and compliant config to use ideal volume: check orders market_depth_size # against total volume before market depth threshold theoretical_used_funds = self._get_total_volume_to_use( side, reference_price, daily_volume, order_prices, available_funds, True ) current_used_funds = sum( amount for amount in self._get_market_depth_order_amounts(closer_to_further_real_orders, reference_price) ) required_source = "ideal funds according to config and trading volume" if current_used_funds < theoretical_used_funds * tolerated_bellow_depth_ratio: self.get_logger().warning( f"{side.name} order book depth is not reached, refresh required. " f"Volume in orders: {current_used_funds}, required: {theoretical_used_funds} (from {required_source}) " f"[trigger source: {trigger_source}]" ) return False if current_used_funds > theoretical_used_funds * tolerated_above_depth_ratio: self.get_logger().warning( f"{side.name} order book depth is exceeded by more than " f"{tolerated_above_depth_ratio * trading_constants.ONE_HUNDRED - trading_constants.ONE_HUNDRED}%, " f"refresh required. Volume in orders: {current_used_funds}, required: {theoretical_used_funds} " f"(from {required_source}) " f"[trigger source: {trigger_source}]" ) return False return True def _get_target_orders( self, side: trading_enums.TradeOrderSide, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, available_base: typing.Optional[decimal.Decimal], available_quote: typing.Optional[decimal.Decimal], symbol_market: dict ) -> list[BookOrderData]: orders_count, start_price, end_price, reference_volume, available_funds = self._get_sided_orders_details( side, reference_price, daily_base_volume, daily_quote_volume, available_base, available_quote, [] ) # order prices are sorted from the inside out of the order book (closest to the price first) order_prices = self._get_order_prices(start_price, end_price, orders_count) total_volume = self._get_total_volume_to_use( side, reference_price, reference_volume, order_prices, available_funds, False ) # order volumes are sorted from the inside out of the order book (closest to the price first) order_volumes = self._get_order_volumes(side, total_volume, order_prices) if side is trading_enums.TradeOrderSide.BUY: # convert quote volume into base order_volumes = [ (volume / order_price) if order_price else volume for volume, order_price in zip(order_volumes, order_prices) ] if len(order_prices) != len(order_volumes): raise ValueError(f"order_prices and order_volumes should have the same size") return [ BookOrderData( trading_personal_data.decimal_adapt_price(symbol_market, price), trading_personal_data.decimal_adapt_quantity(symbol_market, volume), side, ) for price, volume in zip(order_prices, order_volumes) ] def can_create_at_least_one_order(self, sides: list[trading_enums.TradeOrderSide], symbol_market: dict) -> bool: for side in sides: orders = self.bids if side == trading_enums.TradeOrderSide.BUY else self.asks if not self._is_at_least_one_order_valid(orders, symbol_market): return False return True def _is_at_least_one_order_valid(self, orders: list[BookOrderData], symbol_market: dict) -> bool: for order in orders: if trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( order.amount, order.price, symbol_market ): return True return False def validate_config(self): if self.asks_count > MAX_HANDLED_ASKS_ORDERS: raise ValueError( f"A maximum of {MAX_HANDLED_ASKS_ORDERS} asks is supported" ) if self.bids_count > MAX_HANDLED_BIDS_ORDERS: raise ValueError( f"A maximum of {MAX_HANDLED_BIDS_ORDERS} bids is supported" ) if self.max_spread <= self.min_spread: raise ValueError( f"Maximum spread ({float(self.max_spread)}) must be larger than " f"minimum spread ({float(self.min_spread)})." ) allowed_min_spread = decimal.Decimal("2") * TARGET_CUMULATED_VOLUME_PERCENT / trading_constants.ONE_HUNDRED if self.min_spread > allowed_min_spread: raise ValueError( f"Minimum spread should be smaller than {allowed_min_spread}. " f"Minimum spread: {float(self.min_spread)}" ) def _get_sided_orders_details( self, side: trading_enums.TradeOrderSide, reference_price: decimal.Decimal, daily_base_volume: decimal.Decimal, daily_quote_volume: decimal.Decimal, available_base: typing.Optional[decimal.Decimal], available_quote: typing.Optional[decimal.Decimal], other_side_orders: list[BookOrderData] ): self.validate_config() # reverse when other side is BUY, therefore current side is sell first_other_side_price = get_sorted_sided_orders( other_side_orders, True )[0] if other_side_orders else None order_book_price_range = reference_price * (self.max_spread - self.min_spread) / decimal.Decimal("2") flat_min_spread = reference_price * self.min_spread if side is trading_enums.TradeOrderSide.BUY: orders_count = self.bids_count if first_other_side_price is None or first_other_side_price.price - flat_min_spread > reference_price: start_price = reference_price - (flat_min_spread / 2) else: start_price = first_other_side_price.price - flat_min_spread end_price = start_price - order_book_price_range reference_volume = daily_quote_volume available_funds = available_quote else: orders_count = self.asks_count if first_other_side_price is None or first_other_side_price.price + flat_min_spread < reference_price: start_price = reference_price + (flat_min_spread / 2) else: start_price = first_other_side_price.price + flat_min_spread end_price = start_price + order_book_price_range reference_volume = daily_base_volume available_funds = available_base return orders_count, start_price, end_price, reference_volume, available_funds def _get_order_prices( self, start_price: decimal.Decimal, end_price: decimal.Decimal, orders_count: int ) -> list[decimal.Decimal]: if orders_count < 2: raise ValueError("Orders count must be greater than 2") increment = (end_price - start_price) / (orders_count - 1) return [ start_price + (increment * i) for i in range(orders_count) ] def _infer_sided_order_data_after_swaps( self, existing_orders: list[BookOrderData], outdated_orders: list[trading_personal_data.Order], available_funds: decimal.Decimal, reference_price: decimal.Decimal, reference_volume: decimal.Decimal, side: trading_enums.TradeOrderSide ) -> list[BookOrderData]: if not existing_orders and not outdated_orders: # nothing to adapt: return ideal orders return self.bids if side == trading_enums.TradeOrderSide.BUY else self.asks closer_to_further_orders = get_sorted_sided_orders( [o for o in existing_orders if o.side == side], True ) other_side_orders = [o for o in existing_orders if o.side != side] orders_count, ideal_start_price, ideal_end_price, _, _ = self._get_sided_orders_details( side, reference_price, trading_constants.ZERO, trading_constants.ZERO, trading_constants.ZERO, trading_constants.ZERO, other_side_orders, ) ideal_prices = self._get_order_prices(ideal_start_price, ideal_end_price, orders_count) ideal_amount_percents = self._get_order_volumes(side, trading_constants.ONE_HUNDRED, ideal_prices) adapted_orders_data = [] existing_order_index = 0 moving_window_price_ratio = decimal.Decimal("1.5") for i in range(0, len(ideal_prices)): ideal_price = ideal_prices[i] inferred_order_data = InferredOrderData( ideal_price, ideal_amount_percents[i], None, None, None, ideal_price ) previous_ideal_price = ideal_prices[i - 1] if i > 0 else reference_price next_ideal_price = ideal_prices[i + 1] if i < len(ideal_prices) - 1 else None window_min = ideal_price - ( abs(ideal_price - previous_ideal_price) / ( moving_window_price_ratio if i > 0 else decimal.Decimal(1) ) ) window_max = ideal_price + ( (abs(next_ideal_price - ideal_price) / moving_window_price_ratio) if next_ideal_price is not None # fallback to previous price increment else abs(ideal_price - previous_ideal_price) ) # for each ideal price, check if an equivalent exists in current prices candidate_existing_order_index = existing_order_index found_order = False while not found_order and len(closer_to_further_orders) > candidate_existing_order_index: current_order = closer_to_further_orders[candidate_existing_order_index] if window_min <= current_order.price <= window_max: # price and amount are found: keep them inferred_order_data.current_price = current_order.price inferred_order_data.final_price = current_order.price inferred_order_data.current_origin_amount = current_order.amount inferred_order_data.final_amount = current_order.amount found_order = True candidate_existing_order_index += 1 if found_order: # skip existing order from checked orders existing_order_index = candidate_existing_order_index else: # price is missing: it will have to be added pass adapted_orders_data.append(inferred_order_data) self._adapt_inferred_order_amounts( adapted_orders_data, existing_orders, outdated_orders, available_funds, reference_price, reference_volume, side ) return [ BookOrderData(order.final_price, order.final_amount, side) for order in adapted_orders_data ] def _adapt_inferred_order_amounts( self, adapted_orders_data: list[InferredOrderData], existing_orders: list[BookOrderData], outdated_orders: list[trading_personal_data.Order], available_funds: decimal.Decimal, reference_price: decimal.Decimal, reference_volume: decimal.Decimal, side: trading_enums.TradeOrderSide ): if not any(d.final_amount is None for d in adapted_orders_data): # nothing to adapt return # order.filled_quantity is not handled in simulator available_funds_after_outdated_orders_in_quote_or_base = available_funds + sum( (order.origin_quantity - ( trading_constants.ZERO if order.trader.simulate else order.filled_quantity )) * order.origin_price if side == trading_enums.TradeOrderSide.BUY else (order.origin_quantity - ( trading_constants.ZERO if order.trader.simulate else order.filled_quantity )) for order in outdated_orders if order.side == side ) # index missing final amounts reused_order_prices = [ order.final_price for order in adapted_orders_data if order.current_origin_amount is not None ] cancelled_orders = [ order for order in existing_orders if order.side == side and order.price not in reused_order_prices ] available_funds_after_cancelled_orders_in_quote_or_base = sum([ (order.price * order.amount) if order.side == trading_enums.TradeOrderSide.BUY else order.amount for order in cancelled_orders ]) total_available_amount_in_quote_or_base = ( available_funds_after_outdated_orders_in_quote_or_base + available_funds_after_cancelled_orders_in_quote_or_base ) base_total_available_amount = ( total_available_amount_in_quote_or_base / reference_price if side == trading_enums.TradeOrderSide.BUY else total_available_amount_in_quote_or_base ) # infer missing order amounts using found order and ideal percents if base_inferred_amounts := [ o.current_origin_amount * trading_constants.ONE_HUNDRED / o.ideal_amount_percent for o in adapted_orders_data if o.current_origin_amount is not None ]: # use existing orders when possible base_inferred_amount_total_used_amount = sum(base_inferred_amounts) / len(base_inferred_amounts) else: # otherwise use config order_prices = [ order.final_price for order in adapted_orders_data ] inferred_amount_total_used_amount_in_quote_or_base = self._get_total_volume_to_use( side, reference_price, reference_volume, order_prices, total_available_amount_in_quote_or_base, False ) base_inferred_amount_total_used_amount = ( inferred_amount_total_used_amount_in_quote_or_base / reference_price if side == trading_enums.TradeOrderSide.BUY else inferred_amount_total_used_amount_in_quote_or_base ) # get total amount in current orders amount_in_orders = sum( o.current_origin_amount for o in adapted_orders_data if o.current_origin_amount is not None ) # compute missing amount base_missing_amount = base_inferred_amount_total_used_amount - amount_in_orders if base_missing_amount < trading_constants.ZERO: # Means that required amount is lower than current open amount even though orders are missing. This # usually means that trading volume decreased and therefore less quantity is now required. # In this case, a full order book refresh is required raise FullBookRebalanceRequired( f"Too much funds in order book: missing amount in orders is < 0: {base_missing_amount}: " f"{base_inferred_amount_total_used_amount=} " f"{amount_in_orders=} {adapted_orders_data=}" ) # if enough funds: use new %, otherwise adapt max to be available amount "splitable" between orders to create base_usable_total_amount = base_missing_amount if base_total_available_amount < base_missing_amount: # default to available funds if base_missing_amount is not available base_usable_total_amount = base_total_available_amount splittable_base_missing_amount = base_usable_total_amount / sum( inferred_data.ideal_amount_percent / trading_constants.ONE_HUNDRED for inferred_data in adapted_orders_data if inferred_data.current_origin_amount is None ) for inferred_data in adapted_orders_data: if inferred_data.current_origin_amount is None: inferred_data.final_amount = ( inferred_data.ideal_amount_percent / trading_constants.ONE_HUNDRED * splittable_base_missing_amount ) def _get_order_volumes( self, side: trading_enums.TradeOrderSide, total_volume: decimal.Decimal, order_prices: list[decimal.Decimal], multiplier=decimal.Decimal(1), direction=DECREASING ) -> list[decimal.Decimal]: orders_count = len(order_prices) if orders_count < 2: raise ValueError("Orders count must be greater than 2") decimal_orders_count = decimal.Decimal(str(orders_count)) if direction in (INCREASING, DECREASING): average_order_size = total_volume / decimal_orders_count max_size_delta = average_order_size * (multiplier - 1) increment = max_size_delta / decimal_orders_count # base_vol + base_vol + increment + base_vol + 2 x increment + .... = total_volume # order_count: 1 => 0 = 0 increment # order_count: 2 => 0 + 1 = 1 increments # order_count: 3 => 0 + 1 + 2 = 3 increments # order_count: 4 => 0 + 1 + 2 + 3 = 6 increments # order_count: 5 => 0 + 1 + 2 + 3 + 4 = 10 increments total_increments = sum(i for i in range(orders_count)) base_vol = (total_volume - (total_increments * increment)) / decimal_orders_count iterator = range(orders_count) if DECREASING else range(orders_count - 1, 0, -1) # DECREASING : order are smaller when closer to the reference price # INCREASING : order are larger when closer to the reference price order_volumes = [ base_vol + (increment * decimal.Decimal(str(i))) for i in iterator ] else: raise NotImplementedError(f"{direction} not implemented") return order_volumes def _get_total_volume_to_use( self, side: trading_enums.TradeOrderSide, reference_price: decimal.Decimal, reference_volume: decimal.Decimal, order_prices: list[decimal.Decimal], available_funds_base_or_quote: typing.Optional[decimal.Decimal], until_depth_threshold_only: bool, ) -> decimal.Decimal: ideal_total_volume = self._get_ideal_total_volume_to_use( side, reference_price, reference_volume, order_prices, until_depth_threshold_only ) if available_funds_base_or_quote is not None and ideal_total_volume > available_funds_base_or_quote: return available_funds_base_or_quote return ideal_total_volume def _get_market_depth_order_amounts( self, orders: list[BookOrderData], reference_price: decimal.Decimal ) -> list[decimal.Decimal]: return [ order.get_base_amount() for order in orders if abs(trading_constants.ONE_HUNDRED - ( order.price * trading_constants.ONE_HUNDRED / reference_price )) <= TARGET_CUMULATED_VOLUME_PERCENT ] def _get_ideal_total_volume_to_use( self, side: trading_enums.TradeOrderSide, reference_price: decimal.Decimal, reference_volume: decimal.Decimal, order_prices: list[decimal.Decimal], until_depth_threshold_only: bool, daily_trading_volume_percent=DAILY_TRADING_VOLUME_PERCENT ) -> decimal.Decimal: # ideal volume contains daily_trading_volume_percent of daily_volume # within the first target_cumulated_volume_percent of the order book target_before_threshold_volume = ( reference_volume * daily_trading_volume_percent / trading_constants.ONE_HUNDRED ) if until_depth_threshold_only: self.get_logger().info(f"{target_before_threshold_volume=} {daily_trading_volume_percent=}") return target_before_threshold_volume counted_orders = len(self._get_market_depth_order_amounts([ BookOrderData(price, trading_constants.ZERO, side) for price in order_prices ], reference_price)) # goal: the first (closes to reference price) counted_orders orders have a volume of target_volume # use a percent-based volume profile to figure out the total required volume reference_order_volumes = self._get_order_volumes(side, trading_constants.ONE_HUNDRED, order_prices) # volume_before_threshold = % of traded volume that is contained before threshold percent_volume_before_threshold = sum( percent_volume for percent_volume in reference_order_volumes[:counted_orders] ) if percent_volume_before_threshold == trading_constants.ZERO: if not reference_order_volumes: raise ValueError(f"Error: reference_order_volumes can't be empty. {order_prices=}") percent_volume_before_threshold = reference_order_volumes[0] # ideal_total_volume = volume_before_threshold + rest of the volume # ideal_total_volume = ideal_total_volume * percent_volume_before_threshold / 100 + ideal_total_volume * (1 - percent_volume_before_threshold / 100) # Where: ideal_total_volume * percent_volume_before_threshold / 100 = target_before_threshold_volume # Therefore: ideal_total_volume = target_before_threshold_volume + ideal_total_volume * (1 - percent_volume_before_threshold / 100) # ideal_total_volume - ideal_total_volume * (100 - ideal_total_volume) = target_before_threshold_volume # 1 - (1 - percent_volume_before_threshold / 100) = target_before_threshold_volume / ideal_total_volume # ideal_total_volume = target_before_threshold_volume / (1 - (1 - percent_volume_before_threshold * 100)) ideal_total_volume = ( target_before_threshold_volume / percent_volume_before_threshold * trading_constants.ONE_HUNDRED ) # keep up to 10 decimals to avoid floating point precision issues due to percent ratios return _quantize_decimal(ideal_total_volume) @classmethod def get_logger(cls): return commons_logging.get_logger(cls.__name__) def _quantize_decimal(value: decimal.Decimal) -> decimal.Decimal: return value.quantize( _MAX_PRECISION, rounding=decimal.ROUND_HALF_UP ) def get_sorted_sided_orders(orders: list[BookOrderData], closer_to_further: bool) -> list[BookOrderData]: if orders: side = orders[0].side return sorted( orders, key=lambda o: o.price, reverse=side == ( trading_enums.TradeOrderSide.BUY if closer_to_further else trading_enums.TradeOrderSide.SELL ), ) return orders ================================================ FILE: Trading/Mode/market_making_trading_mode/reference_price.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import dataclasses import decimal import enum import typing import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.personal_data as trading_personal_data @dataclasses.dataclass class PriceSource: exchange: str pair: str ================================================ FILE: Trading/Mode/market_making_trading_mode/resources/MarketMakingTradingMode.md ================================================ ## MarketMakingTradingMode A market making strategy that will maintain the configured order book on the target exchange. ### Behavior When started, the strategy will create orders according to its configuration. It might cancel open orders when they are incompatible. As soon as the maintained order book becomes outdated (from a changed reference price or filled/canceled orders), it will be adapted to always try to reflect the configuration. When a full order book replacement takes place, orders are canceled one by one to avoid leaving an empty book. The strategy will use all available funds, up to a maximum of what is necessary to cover 2% of the pair's daily trading volume on the target exchange within the first 3% of the order book depth. Note: The strategy does not create artificial volume by forcing market orders, it focuses on maintaining an optimized order book. ### Configuration - Bids and asks counts define how many orders should be maintained within the book - Min spread is the distance (as a % of the current price) between the highest bid and lowest ask - Max spread is the distance (as a % of the current price) between the lowest bid and highest ask - Reference exchange is the exchange to get the current price of the traded pair from. It should be a very liquid exchange to avoid arbitrage opportunities. An advanced version of this market making strategy is available on [OctoBot Market Making](https://market-making.octobot.cloud?utm_source=octobot_mm&utm_medium=dk&utm_campaign=regular_open_source_content&utm_content=trading_mode_docs). ================================================ FILE: Trading/Mode/market_making_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/market_making_trading_mode/tests/test_market_making_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import contextlib import mock import os import pytest import async_channel.util as channel_util import octobot_commons.constants as commons_constants import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.tests.test_config as test_config import octobot_tentacles_manager.api as tentacles_manager_api import octobot_backtesting.api as backtesting_api import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges import tentacles.Trading.Mode.market_making_trading_mode.market_making_trading as market_making_trading import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges import tests.test_utils.trading_modes as test_trading_modes # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio # binance symbol market extract SYMBOL_MARKET = { 'id': 'BTCUSDT', 'lowercaseId': 'btcusdt', 'symbol': 'BTC/USDT', 'base': 'BTC', 'quote': 'USDT', 'settle': None, 'baseId': 'BTC', 'quoteId': 'USDT', 'settleId': None, 'type': 'spot', 'spot': True, 'margin': True, 'swap': False, 'future': False, 'option': False, 'index': None, 'active': True, 'contract': False, 'linear': None, 'inverse': None, 'subType': None, 'taker': 0.001, 'maker': 0.001, 'contractSize': None, 'expiry': None, 'expiryDatetime': None, 'strike': None, 'optionType': None, 'precision': {'amount': 5, 'price': 2, 'cost': None, 'base': 1e-08, 'quote': 1e-08}, 'limits': { 'leverage': {'min': None, 'max': None}, 'amount': {'min': 1e-05, 'max': 9000.0}, 'price': {'min': 0.01, 'max': 1000000.0}, 'cost': {'min': 5.0, 'max': 9000000.0}, 'market': {'min': 0.0, 'max': 107.1489592} }, 'created': None, 'percentage': True, 'feeSide': 'get', 'tierBased': False } def _get_mm_config(): return { "asks_count": 5, "bids_count": 5, "min_spread": 5, "max_spread": 20, "reference_exchange": "local", } async def _init_trading_mode(config, exchange_manager, symbol): mode = market_making_trading.MarketMakingTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol mode.trading_config = _get_mm_config() await mode.initialize(trading_config=mode.trading_config) # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) test_trading_modes.set_ready_to_start(mode.producers[0]) return mode, mode.producers[0] @contextlib.asynccontextmanager async def _get_tools(symbol, additional_portfolio={}): tentacles_manager_api.reload_tentacle_info() exchange_manager = None try: config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 1000 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "BTC"] = 10 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO].update(additional_portfolio) exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.load_test_tentacles_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() # set BTC/USDT price at 1000 USDT if symbol not in exchange_manager.client_symbols: exchange_manager.client_symbols.append(symbol) trading_api.force_set_mark_price(exchange_manager, symbol, 1000) mode, producer = await _init_trading_mode(config, exchange_manager, symbol) yield producer, mode.get_trading_mode_consumers()[0], exchange_manager finally: if exchange_manager: await _stop(exchange_manager) async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() async def test_handle_market_making_orders_from_no_orders(): symbol = "BTC/USDT" async with _get_tools(symbol) as (producer, consumer, exchange_manager): price = decimal.Decimal(1000) origin_submit_trading_evaluation = producer.submit_trading_evaluation with mock.patch.object( producer, "submit_trading_evaluation", mock.AsyncMock(side_effect=origin_submit_trading_evaluation) ) as submit_trading_evaluation_mock, mock.patch.object( producer, "_get_reference_price", mock.AsyncMock(return_value=price) ) as _get_reference_price_mock, mock.patch.object( producer, "_get_daily_volume", mock.Mock(return_value=(decimal.Decimal(1), decimal.Decimal(1000))) ) as _get_daily_volume_mock: trigger_source = "ref_price" # 1. full replace as no order exist assert await producer._handle_market_making_orders(price, SYMBOL_MARKET, trigger_source, False) is True _get_reference_price_mock.assert_called_once() _get_daily_volume_mock.assert_called_once() submit_trading_evaluation_mock.assert_called_once() assert submit_trading_evaluation_mock.mock_calls[0].kwargs["symbol"] == symbol data = submit_trading_evaluation_mock.mock_calls[0].kwargs["data"] assert data[market_making_trading.MarketMakingTradingModeConsumer.CURRENT_PRICE_KEY] == price assert data[market_making_trading.MarketMakingTradingModeConsumer.SYMBOL_MARKET_KEY] == SYMBOL_MARKET order_plan: market_making_trading.OrdersUpdatePlan = data[market_making_trading.MarketMakingTradingModeConsumer.ORDER_ACTIONS_PLAN_KEY] assert isinstance(order_plan, market_making_trading.OrdersUpdatePlan) assert len(order_plan.order_actions) == 10 buy_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.BUY ] sell_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.SELL ] assert len(buy_actions) == len(sell_actions) == 5 assert order_plan.cancelled == False assert order_plan.cancellable == False # full replace is not cancellable assert not order_plan.processed.is_set() assert order_plan.trigger_source == trigger_source # wait for orders to be created for _ in range(len(order_plan.order_actions)): await asyncio_tools.wait_asyncio_next_cycle() # ensure orders are properly created open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol) assert len(open_orders) == 10 assert sorted([f"{o.origin_price}{o.side.value}" for o in open_orders]) == sorted([ f"{a.order_data.price}{a.order_data.side.value}" for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) ]) _get_reference_price_mock.reset_mock() submit_trading_evaluation_mock.reset_mock() _get_daily_volume_mock.reset_mock() # 2. receive an update but orders are already in place: nothing to do assert await producer._handle_market_making_orders(price, SYMBOL_MARKET, trigger_source, False) is True _get_reference_price_mock.assert_called_once() submit_trading_evaluation_mock.assert_not_called() _get_reference_price_mock.reset_mock() _get_daily_volume_mock.reset_mock() # 3. receive an update, orders are already in place but force_full_refresh is True: refresh orders assert await producer._handle_market_making_orders(price, SYMBOL_MARKET, trigger_source, True) is True _get_reference_price_mock.assert_called_once() _get_daily_volume_mock.assert_called_once() submit_trading_evaluation_mock.assert_called_once() assert submit_trading_evaluation_mock.mock_calls[0].kwargs["symbol"] == symbol data = submit_trading_evaluation_mock.mock_calls[0].kwargs["data"] order_plan: market_making_trading.OrdersUpdatePlan = data[market_making_trading.MarketMakingTradingModeConsumer.ORDER_ACTIONS_PLAN_KEY] assert isinstance(order_plan, market_making_trading.OrdersUpdatePlan) assert len(order_plan.order_actions) == 20 cancel_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CancelOrderAction) ] buy_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.BUY ] sell_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.SELL ] assert len(cancel_actions) == 10 assert len(buy_actions) == len(sell_actions) == 5 assert order_plan.cancelled == False assert order_plan.cancellable == False # full replace is not cancellable assert not order_plan.processed.is_set() assert order_plan.trigger_source == trigger_source # wait for orders to be created for _ in range(len(order_plan.order_actions)): await asyncio_tools.wait_asyncio_next_cycle() # ensure orders are properly created open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol) assert len(open_orders) == 10 assert sorted([f"{o.origin_price}{o.side.value}" for o in open_orders]) == sorted([ f"{a.order_data.price}{a.order_data.side.value}" for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) ]) _get_reference_price_mock.reset_mock() submit_trading_evaluation_mock.reset_mock() async def test_handle_market_making_orders_missing_funds_for_buy_orders(): symbol = "BTC/USDT" async with _get_tools(symbol, additional_portfolio={"USDT": 15}) as (producer, consumer, exchange_manager): price = decimal.Decimal(1000) origin_submit_trading_evaluation = producer.submit_trading_evaluation with mock.patch.object( producer, "submit_trading_evaluation", mock.AsyncMock(side_effect=origin_submit_trading_evaluation) ) as submit_trading_evaluation_mock, mock.patch.object( producer, "_get_reference_price", mock.AsyncMock(return_value=price) ) as _get_reference_price_mock, mock.patch.object( producer, "_get_daily_volume", mock.Mock(return_value=(decimal.Decimal(1), decimal.Decimal(1000))) ) as _get_daily_volume_mock: trigger_source = "ref_price" # 1. full replace as no order exist assert await producer._handle_market_making_orders(price, SYMBOL_MARKET, trigger_source, False) is True _get_reference_price_mock.assert_called_once() _get_daily_volume_mock.assert_called_once() submit_trading_evaluation_mock.assert_called_once() data = submit_trading_evaluation_mock.mock_calls[0].kwargs["data"] order_plan: market_making_trading.OrdersUpdatePlan = data[market_making_trading.MarketMakingTradingModeConsumer.ORDER_ACTIONS_PLAN_KEY] assert len(order_plan.order_actions) == 10 buy_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.BUY ] sell_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.SELL ] assert len(buy_actions) == len(sell_actions) == 5 assert order_plan.cancellable == False # full replace is not cancellable # wait for orders to be created for _ in range(len(order_plan.order_actions)): await asyncio_tools.wait_asyncio_next_cycle() # ensure orders are properly created open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol) # # only sell orders are created assert sorted([f"{o.origin_price}{o.side.value}" for o in open_orders]) == sorted([ f"{a.order_data.price}{a.order_data.side.value}" for a in sell_actions ]) _get_reference_price_mock.reset_mock() submit_trading_evaluation_mock.reset_mock() # 2. receive an update but orders are already in place: nothing to do assert await producer._handle_market_making_orders(price, SYMBOL_MARKET, trigger_source, False) is True _get_reference_price_mock.assert_called_once() submit_trading_evaluation_mock.assert_not_called() _get_reference_price_mock.reset_mock() submit_trading_evaluation_mock.reset_mock() # 3. an order got cancelled: recreate book await exchange_manager.trader.cancel_order(open_orders[0]) assert await producer._handle_market_making_orders(price, SYMBOL_MARKET, trigger_source, False) is True _get_reference_price_mock.assert_called_once() submit_trading_evaluation_mock.assert_called_once() data = submit_trading_evaluation_mock.mock_calls[0].kwargs["data"] order_plan: market_making_trading.OrdersUpdatePlan = data[market_making_trading.MarketMakingTradingModeConsumer.ORDER_ACTIONS_PLAN_KEY] assert len(order_plan.order_actions) == 9 assert order_plan.cancellable == False # full replace is not cancellable buy_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.BUY ] assert not buy_actions sell_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CreateOrderAction) and a.order_data.side == trading_enums.TradeOrderSide.SELL ] assert len(sell_actions) == 5 cancel_actions = [ a for a in order_plan.order_actions if isinstance(a, market_making_trading.CancelOrderAction) ] assert sorted([a.order for a in cancel_actions], key=lambda x: x.origin_price) == ( sorted(open_orders[1:], key=lambda x: x.origin_price) ) # wait for orders to be cancelled and created for _ in range(len(order_plan.order_actions)): await asyncio_tools.wait_asyncio_next_cycle() # ensure orders are properly created open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol) # # only sell orders are created assert sorted([f"{o.origin_price}{o.side.value}" for o in open_orders]) == sorted([ f"{a.order_data.price}{a.order_data.side.value}" for a in sell_actions ]) _get_reference_price_mock.reset_mock() submit_trading_evaluation_mock.reset_mock() ================================================ FILE: Trading/Mode/market_making_trading_mode/tests/test_order_book_distribution.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import pytest import octobot_trading.enums as trading_enums import octobot_trading.constants as trading_constants import tentacles.Trading.Mode.market_making_trading_mode.order_book_distribution as order_book_distribution BIDS_COUNT: int = 5 ASKS_COUNT: int = 5 MIN_SPREAD: decimal.Decimal = decimal.Decimal("0.005") MAX_SPREAD: decimal.Decimal = decimal.Decimal("0.05") # binance symbol market extract SYMBOL_MARKET = { 'id': 'BTCUSDT', 'lowercaseId': 'btcusdt', 'symbol': 'BTC/USDT', 'base': 'BTC', 'quote': 'USDT', 'settle': None, 'baseId': 'BTC', 'quoteId': 'USDT', 'settleId': None, 'type': 'spot', 'spot': True, 'margin': True, 'swap': False, 'future': False, 'option': False, 'index': None, 'active': True, 'contract': False, 'linear': None, 'inverse': None, 'subType': None, 'taker': 0.001, 'maker': 0.001, 'contractSize': None, 'expiry': None, 'expiryDatetime': None, 'strike': None, 'optionType': None, 'precision': {'amount': 5, 'price': 2, 'cost': None, 'base': 1e-08, 'quote': 1e-08}, 'limits': { 'leverage': {'min': None, 'max': None}, 'amount': {'min': 1e-05, 'max': 9000.0}, 'price': {'min': 0.01, 'max': 1000000.0}, 'cost': {'min': 5.0, 'max': 9000000.0}, 'market': {'min': 0.0, 'max': 107.1489592} }, 'created': None, 'percentage': True, 'feeSide': 'get', 'tierBased': False } @pytest.fixture def distribution(): return order_book_distribution.OrderBookDistribution( BIDS_COUNT, ASKS_COUNT, MIN_SPREAD, MAX_SPREAD, ) def test_compute_distribution_base_config(distribution): price = decimal.Decimal("50000.12") daily_base_volume = decimal.Decimal("10.1111111111111111111111111") daily_quote_volume = decimal.Decimal("450000.22222222222222222222222") # without available base / quote values assert distribution is distribution.compute_distribution( price, daily_base_volume, daily_quote_volume, SYMBOL_MARKET ) assert len(distribution.asks) == ASKS_COUNT assert len(distribution.bids) == BIDS_COUNT # buy orders: lower than price, ordered from the highest to the lowest assert [o.price for o in distribution.bids] == [ decimal.Decimal(str(p)) for p in [49875.11, 49593.86, 49312.61, 49031.36, 48750.11] ] highest_buy, lowest_buy = distribution.bids[0].price, distribution.bids[-1].price lowest_sell, highest_sell = distribution.asks[0].price, distribution.asks[-1].price # check spread assert round(lowest_sell - highest_buy, 1) == round(price * MIN_SPREAD, 1) assert round(highest_sell - lowest_buy, 1) == round(price * MAX_SPREAD, 1) # check order book depth provided_asks_volume_at_target_prices = sum( o.amount for o in distribution.asks if o.price <= price * (1 + order_book_distribution.TARGET_CUMULATED_VOLUME_PERCENT / decimal.Decimal(100)) ) min_target_base_volume = daily_base_volume * order_book_distribution.DAILY_TRADING_VOLUME_PERCENT / decimal.Decimal(100) assert min_target_base_volume > decimal.Decimal("0") # use 99.9 of target value to account for decimal trunc assert provided_asks_volume_at_target_prices >= min_target_base_volume * decimal.Decimal("0.999") quote_provided_bids_volume_at_target_prices = sum( o.amount * o.price for o in distribution.bids if o.price >= price * (1 - order_book_distribution.TARGET_CUMULATED_VOLUME_PERCENT / decimal.Decimal(100)) ) min_target_quote_volume = daily_quote_volume * order_book_distribution.DAILY_TRADING_VOLUME_PERCENT / decimal.Decimal(100) assert min_target_quote_volume > decimal.Decimal("0") # use 99.9 of target value to account for decimal trunc assert quote_provided_bids_volume_at_target_prices >= min_target_quote_volume * decimal.Decimal("0.999") # sell orders: higher than price, ordered from the lowest to the highest assert [o.price for o in distribution.asks] == [ decimal.Decimal(str(p)) for p in [50125.12, 50406.37, 50687.62, 50968.87, 51250.12] ] assert [o.amount for o in distribution.bids] == [ decimal.Decimal(str(a)) for a in [0.03609, 0.03629, 0.0365, 0.03671, 0.03692] ] total_bid_size = sum(o.amount for o in distribution.bids) assert total_bid_size assert [o.amount for o in distribution.asks] == [ decimal.Decimal(str(a)) for a in [0.04044, 0.04044, 0.04044, 0.04044, 0.04044] ] trigger_source = "ref_price" available_quote = distribution.get_ideal_total_volume( trading_enums.TradeOrderSide.BUY, price, daily_base_volume, daily_quote_volume, ) available_base = distribution.get_ideal_total_volume( trading_enums.TradeOrderSide.SELL, price, daily_base_volume, daily_quote_volume, ) # ensure distance computation is correct distance_from_ideal_after_swaps = distribution.get_shape_distance_from( distribution.bids + distribution.asks, available_base, available_quote, price, daily_base_volume, daily_quote_volume, trigger_source ) assert 0 < distance_from_ideal_after_swaps < 0.006 def test_compute_distribution_base_config_with_max_available_amounts(distribution): price = decimal.Decimal("50000.12") daily_base_volume = decimal.Decimal("10.1111111111111111111111111") daily_quote_volume = decimal.Decimal("450000.22222222222222222222222") available_base = decimal.Decimal("0.0945") available_quote = decimal.Decimal("199.01") # without available base / quote values assert distribution is distribution.compute_distribution( price, daily_base_volume, daily_quote_volume, SYMBOL_MARKET, available_base=available_base, available_quote=available_quote, ) assert len(distribution.asks) == ASKS_COUNT assert len(distribution.bids) == BIDS_COUNT # price did not change assert [o.price for o in distribution.bids] == [ decimal.Decimal(str(p)) for p in [49875.11, 49593.86, 49312.61, 49031.36, 48750.11] ] # price did not change assert [o.price for o in distribution.asks] == [ decimal.Decimal(str(p)) for p in [50125.12, 50406.37, 50687.62, 50968.87, 51250.12] ] # volumes are reduced according available funds assert [o.amount for o in distribution.bids] == [ decimal.Decimal(str(a)) for a in [0.00079, 0.0008, 0.0008, 0.00081, 0.00081] ] total_bid_size = sum(o.amount * o.price for o in distribution.bids) assert ( available_quote * decimal.Decimal("0.99") <= total_bid_size <= available_quote ) # volumes are reduced according to budget assert [o.amount for o in distribution.asks] == [ decimal.Decimal(str(a)) for a in [0.0189, 0.0189, 0.0189, 0.0189, 0.0189] ] total_ask_size = sum(o.amount for o in distribution.asks) assert ( available_base * decimal.Decimal("0.9999") <= total_ask_size <= available_base ) trigger_source = "ref_price" # ensure distance computation is correct distance_from_ideal_after_swaps = distribution.get_shape_distance_from( distribution.bids + distribution.asks, available_base, available_quote, price, daily_base_volume, daily_quote_volume, trigger_source ) assert 0 < distance_from_ideal_after_swaps < 0.008 def test_infer_full_order_data_after_swaps(distribution): # init ideal distribution price = decimal.Decimal("50000.12") daily_base_volume = decimal.Decimal("10") daily_quote_volume = decimal.Decimal("450000") distribution.bids_count = 5 distribution.asks_count = 5 distribution.min_spread = decimal.Decimal("0.01") distribution.max_spread = decimal.Decimal("0.15") # without available base / quote values distribution.compute_distribution( price, daily_base_volume, daily_quote_volume, SYMBOL_MARKET ) assert distribution.asks assert distribution.bids sorted_ideal_bids = order_book_distribution.get_sorted_sided_orders(distribution.bids, True) sorted_ideal_asks = order_book_distribution.get_sorted_sided_orders(distribution.asks, True) ideal_orders = sorted_ideal_bids + sorted_ideal_asks available_base = decimal.Decimal("0.04") available_quote = decimal.Decimal("2000") # 1. ideal orders are open updated_orders = distribution.infer_full_order_data_after_swaps( ideal_orders, [], available_base, available_quote, price, daily_base_volume, daily_quote_volume ) assert updated_orders == ideal_orders # no scheduled change # 2.a an ideal sell order got filled existing_orders = sorted_ideal_bids + sorted_ideal_asks[1:] updated_orders = distribution.infer_full_order_data_after_swaps( existing_orders, [], available_base, available_quote, price, daily_base_volume, daily_quote_volume ) assert len(updated_orders) == 10 assert updated_orders[0:5] == existing_orders[0:5] # buy orders are identical assert updated_orders[6:10] == existing_orders[5:9] # sell orders are identical # (except for 1st sell, which is not in existing orders) assert round(updated_orders[5].price, 1) == round(sorted_ideal_asks[0].price, 1) def test_validate_config(distribution): distribution.validate_config() # does not raise # bids & asks count distribution.asks_count = order_book_distribution.MAX_HANDLED_ASKS_ORDERS distribution.validate_config() # does not raise distribution.asks_count = order_book_distribution.MAX_HANDLED_ASKS_ORDERS + 1 with pytest.raises(ValueError): distribution.validate_config() distribution.asks_count = order_book_distribution.MAX_HANDLED_ASKS_ORDERS distribution.bids_count = order_book_distribution.MAX_HANDLED_BIDS_ORDERS + 1 with pytest.raises(ValueError): distribution.validate_config() distribution.bids_count = order_book_distribution.MAX_HANDLED_BIDS_ORDERS # min spread distribution.min_spread = distribution.max_spread with pytest.raises(ValueError): distribution.validate_config() distribution.min_spread = distribution.max_spread + 1 with pytest.raises(ValueError): distribution.validate_config() distribution.min_spread = distribution.max_spread - 1 assert 50 > decimal.Decimal("2") * order_book_distribution.TARGET_CUMULATED_VOLUME_PERCENT / trading_constants.ONE_HUNDRED distribution.min_spread = decimal.Decimal(50) with pytest.raises(ValueError): distribution.validate_config() ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/__init__.py ================================================ from .remote_trading_signals_trading import RemoteTradingSignalsTradingMode ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/config/RemoteTradingSignalsTradingMode.json ================================================ { "trading_strategy": "", "max_volume": 50, "required_strategies": [] } ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["RemoteTradingSignalsTradingMode"], "tentacles-requirements": ["remote_trading_signals_trading_mode"] } ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/remote_trading_signals_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import octobot_commons.channels_name as channels_name import octobot_commons.constants as common_constants import octobot_commons.enums as common_enums import octobot_commons.authentication as authentication import octobot_commons.tentacles_management as tentacles_management import octobot_commons.signals as commons_signals import async_channel.channels as channels import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.modes as trading_modes import octobot_trading.errors as errors import octobot_trading.exchanges as exchanges import octobot_trading.signals as trading_signals import octobot_trading.personal_data as personal_data import octobot_trading.modes.script_keywords as script_keywords class RemoteTradingSignalsTradingMode(trading_modes.AbstractTradingMode): def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.merged_symbol = None self.last_signal_description = "" def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.UI.user_input( common_constants.CONFIG_TRADING_SIGNALS_STRATEGY, common_enums.UserInputTypes.TEXT, "", inputs, title="Trading strategy: identifier of the trading strategy to use." ) self.UI.user_input( RemoteTradingSignalsModeConsumer.MAX_VOLUME_PER_BUY_ORDER_CONFIG_KEY, common_enums.UserInputTypes.FLOAT, 100, inputs, min_val=0, max_val=100, title="Maximum volume per buy order in % of quote symbol holdings (USDT for BTC/USDT).", ) self.UI.user_input( RemoteTradingSignalsModeConsumer.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY_CONFIG_KEY, common_enums.UserInputTypes.BOOLEAN, True, inputs, title="Round to minimal size orders if missing funds according to signal. " "Used when copy signals require a volume that doesn't meet the minimal exchange order size." ) @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_current_state(self) -> (str, float): producer_state = "" if self.producers[0].state in (None, trading_enums.EvaluatorStates.UNKNOWN) \ else self.producers[0].state.name return producer_state, self.last_signal_description def get_mode_producer_classes(self) -> list: return [RemoteTradingSignalsModeProducer] def get_mode_consumer_classes(self) -> list: return [RemoteTradingSignalsModeConsumer] async def create_producers(self, auto_start) -> list: producers = await super().create_producers(auto_start) return producers + await self._subscribe_to_signal_feed() async def _subscribe_to_signal_feed(self): channel, created = await trading_signals.create_remote_trading_signal_channel_if_missing( self.exchange_manager ) if self.exchange_manager.is_backtesting: # TODO: create and return producer simulator with this bot id raise NotImplementedError("signal producer simulator is not implemented") return [] if created: # only subscribe once to the signal channel try: await channel.subscribe_to_product_feed( self.trading_config[common_constants.CONFIG_TRADING_SIGNALS_STRATEGY] ) except (authentication.AuthenticationRequired, authentication.AuthenticationError) as e: self.logger.exception(e, True, f"Error while subscribing to signal feed: {e}. Please sign in to " f"your OctoBot account to receive trading signals") except Exception as e: self.logger.exception(e, True, f"Error while subscribing to signal feed: {e}. This trading mode won't " f"be operating") return [] async def create_consumers(self) -> list: consumers = await super().create_consumers() signals_consumer = await channels.get_chan( channels_name.OctoBotCommunityChannelsName.REMOTE_TRADING_SIGNALS_CHANNEL.value)\ .new_consumer( self._remote_trading_signal_callback, identifier=self.trading_config[common_constants.CONFIG_TRADING_SIGNALS_STRATEGY], symbol=self.symbol, bot_id=self.bot_id ) return consumers + [signals_consumer] async def _remote_trading_signal_callback(self, identifier, exchange, symbol, version, bot_id, signal): self.logger.info(f"received signal: {signal}") await self.producers[0].signal_callback(signal) self.logger.info("done") @classmethod def get_is_symbol_wildcard(cls) -> bool: return False @staticmethod def is_backtestable(): return False def is_following_trading_signals(self): return True async def stop(self) -> None: self.logger.debug("Stopping trading mode: this should normally not be happening unless OctoBot is stopping") await super().stop() class RemoteTradingSignalsModeConsumer(trading_modes.AbstractTradingModeConsumer): MAX_VOLUME_PER_BUY_ORDER_CONFIG_KEY = "max_volume" ROUND_TO_MINIMAL_SIZE_IF_NECESSARY_CONFIG_KEY = "round_to_minimal_size_if_necessary" def __init__(self, trading_mode): super().__init__(trading_mode) self.MAX_VOLUME_PER_BUY_ORDER = \ decimal.Decimal(f"{self.trading_mode.trading_config.get(self.MAX_VOLUME_PER_BUY_ORDER_CONFIG_KEY, 100)}") self.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = \ self.trading_mode.trading_config.get(self.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY_CONFIG_KEY) async def init_user_inputs(self, should_clear_inputs): self.MAX_VOLUME_PER_BUY_ORDER = \ decimal.Decimal(f"{self.trading_mode.trading_config.get(self.MAX_VOLUME_PER_BUY_ORDER_CONFIG_KEY, 100)}") self.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = \ self.trading_mode.trading_config.get(self.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY_CONFIG_KEY) async def internal_callback( self, trading_mode_name, cryptocurrency, symbol, time_frame, final_note, state, data: commons_signals.Signal, dependencies=None ): """ Override not to call self.create_order_if_possible to skip portfolio checks and handle signals directly """ try: await self.handle_signal(symbol, data) except errors.MissingMinimalExchangeTradeVolume: self.logger.info(self.get_minimal_funds_error(symbol, final_note)) except Exception as e: self.logger.exception(e, True, f"Error when handling remote signal orders: {e}") async def handle_signal(self, symbol, data: commons_signals.Signal): if data.topic == trading_enums.TradingSignalTopics.ORDERS.value: # creates a new order (or multiple split orders), always check self.can_create_order() first. await self._handle_signal_orders(symbol, data) elif data.topic == trading_enums.TradingSignalTopics.POSITIONS.value: await self._handle_positions_signal(symbol, data) else: self.logger.error(f"Unhandled signal topic: {data.topic} (signal: {data})") async def _handle_positions_signal(self, symbol: str, signal: commons_signals.Signal): action = signal.content.get(trading_enums.TradingSignalCommonsAttrs.ACTION.value) if action == trading_enums.TradingSignalPositionsActions.EDIT.value: await self._edit_position(symbol, signal) else: self.logger.error(f"Unhandled signal action: {action} (signal: {signal})") async def _edit_position(self, symbol: str, signal: commons_signals.Signal): leverage = signal.content.get(trading_enums.TradingSignalPositionsAttrs.LEVERAGE.value) side = signal.content.get(trading_enums.TradingSignalPositionsAttrs.SIDE.value) if side: side = trading_enums.PositionSide(side) if leverage is not None: await self._set_leverage(symbol, decimal.Decimal(str(leverage)), side) async def _handle_signal_orders(self, symbol: str, signal: commons_signals.Signal): to_create_orders_descriptions, to_edit_orders_descriptions, \ to_cancel_orders_descriptions, to_group_orders_descriptions = \ self._parse_signal_orders(signal) self._update_orders_according_to_config(to_edit_orders_descriptions) self._update_orders_according_to_config(to_create_orders_descriptions) await self._group_orders(to_group_orders_descriptions, symbol) cancelled_count = await self._cancel_orders(to_cancel_orders_descriptions, symbol) edited_count = await self._edit_orders(to_edit_orders_descriptions, symbol) created_count = await self._create_orders(to_create_orders_descriptions, symbol) self.trading_mode.last_signal_description = \ f"Last signal: {created_count} new order{'s' if created_count > 1 else ''}" # send_notification if not self.exchange_manager.is_backtesting: await self._send_alert_notification(symbol, created_count, edited_count, cancelled_count) async def _group_orders(self, orders_descriptions, symbol): groups = [] orders_by_group_id = {} for order_description, order in self.get_open_order_from_description(orders_descriptions, symbol): order_group = self._get_or_create_order_group( order_description, order_description[trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value]) if order_group not in groups: groups.append(order_group) orders_by_group_id[order_group.name] = [] order.add_to_order_group(order_group) orders_by_group_id[order_group.name].append(order) for group in groups: # in futures, inactive orders are not necessary if self.exchange_manager.trader.enable_inactive_orders and not self.exchange_manager.is_future: await group.get_active_order_swap_strategy.apply_inactive_orders(orders_by_group_id[group.name]) async def _cancel_orders(self, orders_descriptions, symbol): cancelled_count = 0 for _, order in self.get_open_order_from_description(orders_descriptions, symbol): try: await self._cancel_order_on_exchange(order) except (errors.OrderCancelError, errors.UnexpectedExchangeSideOrderStateError) as err: self.logger.warning(f"Skipping order cancel: {err} ({err.__class__.__name__})") cancelled_count += 1 return cancelled_count async def _edit_orders(self, orders_descriptions, symbol): edited_count = 0 for order_description, order in self.get_open_order_from_description(orders_descriptions, symbol): edited_price = order_description[trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value] edited_stop_price = order_description[trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value] edited_quantity, _, _ = await self._get_quantity_from_signal_percent( order_description, order.side, symbol, order.reduce_only, True ) await self._edit_order_on_exchange( order, edited_quantity=decimal.Decimal(edited_quantity) if edited_quantity else None, edited_price=decimal.Decimal(edited_price) if edited_price else None, edited_stop_price=decimal.Decimal(edited_stop_price) if edited_stop_price else None ) edited_count += 1 return edited_count async def _get_quantity_from_signal_percent(self, order_description, side, symbol, reduce_only, update_amount): quantity_type, quantity = script_keywords.parse_quantity( order_description[ trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value if update_amount else trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value ] ) portfolio_type = common_constants.PORTFOLIO_TOTAL if quantity_type is script_keywords.QuantityType.PERCENT \ else common_constants.PORTFOLIO_AVAILABLE current_symbol_holding, current_market_holding, market_quantity, current_price, symbol_market = \ await personal_data.get_pre_order_data(self.exchange_manager, symbol=symbol, timeout=trading_constants.ORDER_DATA_FETCHING_TIMEOUT, portfolio_type=portfolio_type) if self.exchange_manager.is_future: max_order_size, _ = personal_data.get_futures_max_order_size( self.exchange_manager, symbol, side, current_price, reduce_only, current_symbol_holding, market_quantity ) position_percent = order_description[ trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value if update_amount else trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value ] if position_percent is not None: quantity_type, quantity = script_keywords.parse_quantity(position_percent) if quantity_type in ( script_keywords.QuantityType.POSITION_PERCENT, script_keywords.QuantityType.POSITION_PERCENT_ALIAS ): open_position_size_val = \ self.exchange_manager.exchange_personal_data.positions_manager.get_symbol_position( symbol, trading_enums.PositionSide.BOTH ).size target_size = open_position_size_val * quantity / trading_constants.ONE_HUNDRED order_size = abs(target_size - open_position_size_val) return order_size, current_price, max_order_size raise errors.InvalidArgumentError(f"Unhandled position based quantity type: {position_percent}") else: max_order_size = market_quantity if side is trading_enums.TradeOrderSide.BUY else current_symbol_holding return max_order_size * quantity / trading_constants.ONE_HUNDRED, current_price, max_order_size async def _bundle_order(self, order_description, to_create_orders, ignored_orders, bundled_with, fees_currency_side, created_groups, symbol): chained_order = await self._create_order(order_description, symbol, created_groups, fees_currency_side) try: main_order = to_create_orders[bundled_with][0] # always align bundled order quantity with the main order one chained_order.update(chained_order.symbol, quantity=main_order.origin_quantity) params = await self.exchange_manager.trader.bundle_chained_order_with_uncreated_order( main_order, chained_order, chained_order.update_with_triggering_order_fees ) to_create_orders[bundled_with][1].update(params) except KeyError: if bundled_with in ignored_orders: self.logger.error(f"Ignored order bundled to id {bundled_with}: " f"associated master order has not been created") async def _chain_order(self, order_description, created_orders, ignored_orders, chained_to, fees_currency_side, created_groups, symbol, order_description_by_id): failed_order_creation = False try: base_order = created_orders[chained_to] if base_order is None: # when an error occurred when creating the initial order failed_order_creation = True raise KeyError except KeyError as e: if chained_to in ignored_orders or failed_order_creation: message = f"Ignored order chained to id {chained_to}: associated master order has not been created" if failed_order_creation: self.logger.info(message) else: self.logger.error(message) else: self.logger.error( f"Ignored chained order from {order_description}. Chained orders have to be sent in the same " f"signal as the order they are chained to. Missing order with id: {e}.") return 0 desc_base_order_quantity = \ order_description_by_id[chained_to][trading_enums.TradingSignalOrdersAttrs.QUANTITY.value] desc_chained_order_quantity = order_description[trading_enums.TradingSignalOrdersAttrs.QUANTITY.value] # compute target quantity based on the base order's description quantity to keep accurate % target_quantity = decimal.Decimal(f"{desc_chained_order_quantity}") * base_order.origin_quantity / \ decimal.Decimal(f"{desc_base_order_quantity}") chained_order = await self._create_order( order_description, symbol, created_groups, fees_currency_side, target_quantity=target_quantity ) if chained_order.origin_quantity == trading_constants.ZERO: self.logger.warning(f"Ignored chained order: {chained_order}: not enough funds") return 0 await self.exchange_manager.trader.chain_order( base_order, chained_order, chained_order.update_with_triggering_order_fees, False ) if base_order.state is not None and base_order.is_filled() and chained_order.should_be_created(): try: await personal_data.create_as_chained_order(chained_order) except errors.OctoBotExchangeError as err: # todo later on: handle locale error if necessary self.logger.error(f"Failed to create chained order: {chained_order}: {err}") return 1 return 0 async def _create_order(self, order_description, symbol, created_groups, fees_currency_side, target_quantity=None): group = None if group_id := order_description[trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value]: group = created_groups[group_id] side = trading_enums.TradeOrderSide(order_description[trading_enums.TradingSignalOrdersAttrs.SIDE.value]) reduce_only = order_description[trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value] quantity, current_price, max_order_size = await self._get_quantity_from_signal_percent( order_description, side, symbol, reduce_only, False ) quantity = target_quantity or quantity symbol_market = self.exchange_manager.exchange.get_market_status(symbol, with_fixer=False) adapted_quantity = personal_data.decimal_adapt_quantity(symbol_market, quantity) if adapted_quantity == trading_constants.ZERO: if self.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY: adapted_max_size = personal_data.decimal_adapt_quantity(symbol_market, max_order_size) if adapted_max_size > trading_constants.ZERO: try: adapted_quantity = personal_data.get_minimal_order_amount(symbol_market) self.logger.info(f"Minimal order amount reached, rounding to {adapted_quantity}") except errors.NotSupported as e: self.logger.warning(f"Impossible to round order to minimal order size: {e}.") else: funds_options = " or increase leverage" if self.exchange_manager.is_future else "" self.logger.warning(f"Not enough funds to create minimal size order: current maximum order " f"size={max_order_size}. Add funds{funds_options} to be able to trade.") else: self.logger.info("Not enough funds to create order based on signal target amount. " "Enable minimal size rounding to still trade in this situation. " "Add funds or increase leverage to be able to trade in this setup.") price = order_description[trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value] \ or order_description[trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value] adapted_price = personal_data.decimal_adapt_price(symbol_market, decimal.Decimal(f"{price}")) order_type = trading_enums.TraderOrderType( order_description[trading_enums.TradingSignalOrdersAttrs.TYPE.value] ) if order_type in (trading_enums.TraderOrderType.BUY_MARKET, trading_enums.TraderOrderType.SELL_MARKET): # side param is not supported for these orders side = None associated_entries = order_description.get( trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value, None ) trigger_above = order_description.get(trading_enums.TradingSignalOrdersAttrs.TRIGGER_ABOVE.value, None) trailing_profile = None if trailing_profile_details := order_description.get( trading_enums.TradingSignalOrdersAttrs.TRAILING_PROFILE.value ): trailing_profile = personal_data.create_trailing_profile( personal_data.TrailingProfileTypes( order_description[trading_enums.TradingSignalOrdersAttrs.TRAILING_PROFILE_TYPE.value], ), trailing_profile_details ) is_active = order_description.get(trading_enums.TradingSignalOrdersAttrs.IS_ACTIVE.value, True) active_trigger_price = ( None if order_description.get(trading_enums.TradingSignalOrdersAttrs.ACTIVE_TRIGGER_PRICE.value) is None else decimal.Decimal(str( order_description[trading_enums.TradingSignalOrdersAttrs.ACTIVE_TRIGGER_PRICE.value] )) ) active_trigger_above = order_description.get(trading_enums.TradingSignalOrdersAttrs.ACTIVE_TRIGGER_ABOVE.value) cancel_policy = None if cancel_policy_type := order_description.get( trading_enums.TradingSignalOrdersAttrs.CANCEL_POLICY_TYPE.value ): cancel_policy = personal_data.create_cancel_policy( cancel_policy_type, order_description[trading_enums.TradingSignalOrdersAttrs.CANCEL_POLICY_KWARGS.value] ) order = personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=symbol, current_price=current_price, quantity=adapted_quantity, price=adapted_price, side=side, trigger_above=trigger_above, tag=order_description[trading_enums.TradingSignalOrdersAttrs.TAG.value], order_id=order_description[trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value], group=group, fees_currency_side=fees_currency_side, reduce_only=reduce_only, associated_entry_id=associated_entries[0] if associated_entries else None, trailing_profile=trailing_profile, is_active=is_active, active_trigger_price=active_trigger_price, active_trigger_above=active_trigger_above, cancel_policy=cancel_policy ) if associated_entries and len(associated_entries) > 1: for associated_entry in associated_entries[1:]: order.associate_to_entry(associated_entry) order.update_with_triggering_order_fees = order_description.get( trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value, False ) return order def _get_or_create_order_group(self, order_description, group_id): group_type = order_description[trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value] group_class = tentacles_management.get_deep_class_from_parent_subclasses(group_type, personal_data.OrderGroup) active_order_swap_strategy = None if active_strategy_type := order_description.get( trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TYPE.value ): active_order_swap_strategy = tentacles_management.get_deep_class_from_parent_subclasses( active_strategy_type, personal_data.ActiveOrderSwapStrategy )( swap_timeout= order_description[trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TIMEOUT.value], trigger_price_configuration= order_description[trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TRIGGER_CONFIG.value] ) return self.exchange_manager.exchange_personal_data.orders_manager.get_or_create_group( group_class, group_id, active_order_swap_strategy=active_order_swap_strategy ) async def _create_orders(self, orders_descriptions, symbol): to_create_orders = {} # dict of (orders, orders_param) created_groups = {} created_orders = {} ignored_orders = set() order_description_by_id = { orders_description[trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value]: orders_description for orders_description in orders_descriptions } fees_currency_side = None if self.exchange_manager.is_future: fees_currency_side = self.exchange_manager.exchange.get_pair_future_contract(symbol)\ .get_fees_currency_side() for order_description in orders_descriptions: if group_id := order_description[trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value]: group = self._get_or_create_order_group(order_description, group_id) await group.enable(False) created_groups[group_id] = group if order_description[trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value] is not None: # bundled orders are created later on continue if order_description[trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value] is not None: # chained orders are created later on continue created_order = await self._create_order(order_description, symbol, created_groups, fees_currency_side) if created_order.origin_quantity == trading_constants.ZERO: order_id = order_description[trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value] self.logger.error(f"Impossible to create order: {created_order} " f"(id: {order_id}): not enough funds on the account.") ignored_orders.add(order_id) else: to_create_orders[order_description[ trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value] ] = (created_order, {}) for order_description in orders_descriptions: if bundled_with := order_description[trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value]: await self._bundle_order(order_description, to_create_orders, ignored_orders, bundled_with, fees_currency_side, created_groups, symbol) # create orders already_handled_order_ids = self.exchange_manager.exchange_personal_data.orders_manager\ .get_all_active_and_pending_orders_id() for order_id, order_with_param in to_create_orders.items(): if order_id in already_handled_order_ids: self.logger.debug(f"Ignored order with order id {order_id}: order already handled") continue created_orders[order_id] = \ await self._create_order_on_exchange(order_with_param[0], params=order_with_param[1]) # handle chained orders created_chained_orders_count = 0 for order_description in orders_descriptions: if (chained_to := order_description[trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value]) \ and order_description[trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value] is None: order_id = \ order_description[trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value] if order_id in already_handled_order_ids: self.logger.debug( f"Ignored order with order id {order_id}: order already handled" ) continue created_chained_orders_count += \ await self._chain_order(order_description, created_orders, ignored_orders, chained_to, fees_currency_side, created_groups, symbol, order_description_by_id) for group in created_groups.values(): await group.enable(True) return len(to_create_orders) + created_chained_orders_count def get_open_order_from_description(self, order_descriptions, symbol): found_orders = [] for order_description in order_descriptions: # filter orders using order_id if accurate_orders := [ (order_description, order) for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol=symbol) if order.order_id == order_description[trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value] ]: found_orders += accurate_orders continue # 2nd chance: use order type and price as these are kept between bot restarts (loaded from exchange) orders = [ (order_description, order) for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(symbol=symbol) if (order.origin_price == order_description[trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value] or order.origin_price == order_description[trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value]) and self._is_compatible_order_type(order, order_description) ] if orders: found_orders += orders else: self.logger.error( f"Order not found on {self.exchange_manager.exchange_name} open orders, " f"description: {order_description}" ) return found_orders def _is_compatible_order_type(self, order, order_description): side = order_description[trading_enums.TradingSignalOrdersAttrs.SIDE.value] if not order.side.value == side: return False order_type = order_description[trading_enums.TradingSignalOrdersAttrs.TYPE.value] return personal_data.TraderOrderTypeClasses[order_type] == order.__class__ def _parse_signal_orders(self, signal: commons_signals.Signal): to_create_orders = [] to_edit_orders = [] to_cancel_orders = [] to_group_orders = [] for order_description in [signal.content] + self._get_nested_signal_order_descriptions(signal.content): action = order_description.get(trading_enums.TradingSignalCommonsAttrs.ACTION.value) if action == trading_enums.TradingSignalOrdersActions.CREATE.value: to_create_orders.append(order_description) elif action == trading_enums.TradingSignalOrdersActions.EDIT.value: to_edit_orders.append(order_description) elif action == trading_enums.TradingSignalOrdersActions.CANCEL.value: to_cancel_orders.append(order_description) elif action == trading_enums.TradingSignalOrdersActions.ADD_TO_GROUP.value: to_group_orders.append(order_description) return to_create_orders, to_edit_orders, to_cancel_orders, to_group_orders def _get_nested_signal_order_descriptions(self, order_description): order_descriptions = [] for nested_desc in order_description.get(trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value) or []: order_descriptions.append(nested_desc) # also explore multiple level nested signals order_descriptions += self._get_nested_signal_order_descriptions(nested_desc) return order_descriptions def _update_orders_according_to_config(self, order_descriptions): for order_description in order_descriptions: self._update_according_to_config(order_description) def _update_according_to_config(self, order_description): # filter max buy order size side = order_description.get(trading_enums.TradingSignalOrdersAttrs.SIDE.value, None) if side is None: found_orders = self.get_open_order_from_description([order_description], None) try: side = found_orders[0][1].side.value except KeyError: pass if side is trading_enums.TradeOrderSide.BUY.value: for key in (trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value): self._update_quantity_according_to_config(order_description, key) def _update_quantity_according_to_config(self, order_description, quantity_key): quantity_type, quantity = script_keywords.parse_quantity(order_description[quantity_key]) if quantity is not None and quantity > self.MAX_VOLUME_PER_BUY_ORDER: self.logger.warning(f"Updating signal order {quantity_key} from {quantity}{quantity_type.value} " f"to {self.MAX_VOLUME_PER_BUY_ORDER}{quantity_type.value}") order_description[quantity_key] = f"{self.MAX_VOLUME_PER_BUY_ORDER}{quantity_type.value}" async def _send_alert_notification(self, symbol, created, edited, cancelled): try: import octobot_services.api as services_api import octobot_services.enums as services_enum title = f"New trading signal for {symbol}" messages = [] if created: messages.append(f"- Created {created} order{'s' if created > 1 else ''}") if edited: messages.append(f"- Edited {edited} order{'s' if edited > 1 else ''}") if cancelled: messages.append(f"- Cancelled {cancelled} order{'s' if cancelled > 1 else ''}") content = "\n".join(messages) await services_api.send_notification(services_api.create_notification( content, title=title, category=services_enum.NotificationCategory.TRADING_SCRIPT_ALERTS )) except ImportError as e: self.logger.exception(e, True, f"Impossible to send notification: {e}") # exchange methods: bypass trading modes api to avoid sending signals async def _create_order_on_exchange(self, order, params): return await self.exchange_manager.trader.create_order(order, params=params) async def _cancel_order_on_exchange(self, order): await self.exchange_manager.trader.cancel_order(order) async def _edit_order_on_exchange(self, order, edited_quantity=None, edited_price=None, edited_stop_price=None): await self.exchange_manager.trader.edit_order( order, edited_quantity=edited_quantity, edited_price=edited_price, edited_stop_price=edited_stop_price ) async def _set_leverage(self, symbol: str, leverage: decimal.Decimal, side): context = script_keywords.get_base_context(self.trading_mode, symbol=symbol) await script_keywords.set_leverage(context, leverage, side=side) class RemoteTradingSignalsModeProducer(trading_modes.AbstractTradingModeProducer): def get_channels_registration(self): # trading mode is waking up this producer directly from signal channel return [] async def signal_callback(self, signal): exchange_type = signal.content[trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value] if exchange_type == exchanges.get_exchange_type(self.exchange_manager).value: state = trading_enums.EvaluatorStates.UNKNOWN symbol = ( signal.content.get(trading_enums.TradingSignalOrdersAttrs.SYMBOL.value) or signal.content.get(trading_enums.TradingSignalPositionsAttrs.SYMBOL.value) ) await self._set_state( self.trading_mode.cryptocurrency, symbol, state, signal ) else: self.logger.error(f"Incompatible signal exchange type: {exchange_type} " f"with current exchange: {self.exchange_manager}") async def _set_state(self, cryptocurrency: str, symbol: str, new_state, signal): async with self.trading_mode_trigger(): self.state = new_state self.logger.info(f"[{symbol}] update state: {self.state.name}") # call orders creation from consumers await self.submit_trading_evaluation(cryptocurrency=cryptocurrency, symbol=symbol, time_frame=None, final_note=self.final_eval, state=self.state, data=signal) async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() await super().stop() ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/resources/RemoteTradingSignalsTradingMode.md ================================================ RemoteTradingSignalsTradingMode trades using signals from community strategies you are subscribed to. Note: by default, if you don't meet the minimal exchange requirements for order size, the smallest possible order size will be used. This can be disabled in options. _This trading mode supports PNL history when the signal emitter supports it as well._ ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import os.path import mock import pytest import pytest_asyncio import octobot_backtesting.api as backtesting_api import async_channel.util as channel_util import async_channel.channels as channels import octobot_commons.channels_name as channels_names import octobot_commons.tests.test_config as test_config import octobot_commons.constants as commons_constants import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.signals as signals import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.exchanges as exchanges import octobot_trading.personal_data as trading_personal_data import octobot_trading.enums as trading_enums import octobot_trading.signals as trading_signals import tentacles.Trading.Mode as modes import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges from tentacles.Trading.Mode.remote_trading_signals_trading_mode.remote_trading_signals_trading import \ RemoteTradingSignalsTradingMode import octobot_tentacles_manager.api as tentacles_manager_api @pytest_asyncio.fixture async def local_trader(exchange_name="binance", backtesting=None, symbol="BTC/USDT:USDT"): tentacles_manager_api.reload_tentacle_info() exchange_manager = None signal_channel = None try: config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, exchange_name) exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = backtesting or await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = modes.RemoteTradingSignalsTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol # avoid error when trying to connect to server signals with mock.patch.object(RemoteTradingSignalsTradingMode, "_subscribe_to_signal_feed", new=mock.AsyncMock(return_value=[])) \ as _subscribe_to_signal_feed_mock: signal_channel, created = await trading_signals.create_remote_trading_signal_channel_if_missing( exchange_manager ) assert created is True await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) # set BTC/USDT price at 1000 USDT trading_api.force_set_mark_price(exchange_manager, symbol, 1000) # let trading modes start await asyncio_tools.wait_asyncio_next_cycle() _subscribe_to_signal_feed_mock.assert_called_once() yield mode.producers[0], mode.get_trading_mode_consumers()[0], trader finally: if exchange_manager is not None: for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) if exchange_manager.exchange.backtesting.time_updater is not None: await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() if signal_channel is not None: await signal_channel.stop() channels.del_chan(channels_names.OctoBotCommunityChannelsName.REMOTE_TRADING_SIGNALS_CHANNEL.value) SIGNAL_TOPIC = trading_enums.TradingSignalTopics.ORDERS.value @pytest.fixture def mocked_sell_limit_signal(): return signals.Signal( SIGNAL_TOPIC, { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.SELL_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.3574085830652285%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 1010.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.TRIGGER_ABOVE.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "46a0b2de-5b8f-4a39-89a0-137504f83dfc", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.BalancedTakeProfitAndStopOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TYPE.value: trading_personal_data.StopFirstActiveOrderSwapStrategy.__name__, trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TIMEOUT.value: 3, trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TRIGGER_CONFIG.value: trading_enums.ActiveOrderSwapTriggerPriceConfiguration.FILLING_PRICE.value, trading_enums.TradingSignalOrdersAttrs.ACTIVE_TRIGGER_PRICE.value: 21, trading_enums.TradingSignalOrdersAttrs.ACTIVE_TRIGGER_ABOVE.value: False, trading_enums.TradingSignalOrdersAttrs.IS_ACTIVE.value: False, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5705d395-f970-45d9-9ba8-f63da17f17b2", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: True, }, dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123"), mock.Mock(order_id="456")]) ) @pytest.fixture def mocked_sell_limit_signal_with_trailing_group(): return signals.Signal( SIGNAL_TOPIC, { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.SELL_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.3574085830652285%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 1010.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.TRIGGER_ABOVE.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "46a0b2de-5b8f-4a39-89a0-137504f83dfc", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.TrailingOnFilledTPBalancedOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5705d395-f970-45d9-9ba8-f63da17f17b2", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: True, }, dependencies=trading_signals.get_orders_dependencies([]) ) @pytest.fixture def mocked_update_leverage_signal(): return signals.Signal( trading_enums.TradingSignalTopics.POSITIONS.value, { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.EDIT.value, trading_enums.TradingSignalPositionsAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalPositionsAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalPositionsAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.FUTURE.value, trading_enums.TradingSignalPositionsAttrs.STRATEGY.value: "plop strategy", trading_enums.TradingSignalPositionsAttrs.SIDE.value: None, trading_enums.TradingSignalPositionsAttrs.LEVERAGE.value: 10, } ) @pytest.fixture def mocked_buy_limit_signal(): return signals.Signal( SIGNAL_TOPIC, { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.BUY.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.BUY_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.3574085830652285%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 888.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5705d395-f970-45d9-9ba8-f63da17f17b2", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, }, dependencies=trading_signals.get_orders_dependencies([mock.Mock(order_id="123"), mock.Mock(order_id="456")]) ) @pytest.fixture def mocked_bundle_stop_loss_in_sell_limit_signal(mocked_sell_limit_signal): mocked_sell_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.STOP_LOSS.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.356892%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 9990.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "46a0b2de-5b8f-4a39-89a0-137504f83dfc", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.BalancedTakeProfitAndStopOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5ad2a999-5ac2-47f0-9b69-c75a36f3858a", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: True, } ) return mocked_sell_limit_signal @pytest.fixture def mocked_bundle_stop_loss_in_sell_limit_in_market_signal(mocked_sell_limit_signal, mocked_buy_market_signal): trailing_profile = trading_personal_data.FilledTakeProfitTrailingProfile([ trading_personal_data.TrailingPriceStep(price, price, True) for price in (10000, 12000, 13000) ]) mocked_sell_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.STOP_LOSS.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.356892%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 9990.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "46a0b2de-5b8f-4a39-89a0-137504f83dfc", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.BalancedTakeProfitAndStopOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TYPE.value: trading_personal_data.StopFirstActiveOrderSwapStrategy.__name__, trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TIMEOUT.value: 3, trading_enums.TradingSignalOrdersAttrs.ACTIVE_SWAP_STRATEGY_TRIGGER_CONFIG.value: trading_enums.ActiveOrderSwapTriggerPriceConfiguration.FILLING_PRICE.value, trading_enums.TradingSignalOrdersAttrs.TRAILING_PROFILE_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TRAILING_PROFILE.value: None, trading_enums.TradingSignalOrdersAttrs.CANCEL_POLICY_TYPE.value: trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy.__name__, trading_enums.TradingSignalOrdersAttrs.CANCEL_POLICY_KWARGS.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5ad2a999-5ac2-47f0-9b69-c75a36f3858a", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: False, } ) mocked_buy_market_signal.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( mocked_sell_limit_signal.content ) return mocked_buy_market_signal @pytest.fixture def mocked_bundle_trailing_stop_loss_in_sell_limit_in_market_signal(mocked_sell_limit_signal_with_trailing_group, mocked_buy_market_signal): trailing_profile = trading_personal_data.FilledTakeProfitTrailingProfile([ trading_personal_data.TrailingPriceStep(price, price, True) for price in (10000, 12000, 13000) ]) mocked_sell_limit_signal_with_trailing_group.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.STOP_LOSS.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.356892%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 9990.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "46a0b2de-5b8f-4a39-89a0-137504f83dfc", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.TrailingOnFilledTPBalancedOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TRAILING_PROFILE_TYPE.value: trading_personal_data.TrailingProfileTypes.FILLED_TAKE_PROFIT.value, trading_enums.TradingSignalOrdersAttrs.TRAILING_PROFILE.value: trailing_profile.to_dict(), trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5ad2a999-5ac2-47f0-9b69-c75a36f3858a", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: False, trading_enums.TradingSignalOrdersAttrs.CANCEL_POLICY_TYPE.value: trading_personal_data.ExpirationTimeOrderCancelPolicy.__name__, trading_enums.TradingSignalOrdersAttrs.CANCEL_POLICY_KWARGS.value: { "expiration_time": 1000.0, }, } ) mocked_buy_market_signal.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( mocked_sell_limit_signal_with_trailing_group.content ) return mocked_buy_market_signal @pytest.fixture def mocked_bundle_trigger_above_stop_loss_in_sell_limit_in_market_signal(mocked_sell_limit_signal, mocked_buy_market_signal): mocked_sell_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.STOP_LOSS.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.356892%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 999999990.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.TRIGGER_ABOVE.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "46a0b2de-5b8f-4a39-89a0-137504f83dfc", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.BalancedTakeProfitAndStopOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long exit (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "5ad2a999-5ac2-47f0-9b69-c75a36f3858a", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: False, } ) mocked_buy_market_signal.content[trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value].append( mocked_sell_limit_signal.content ) return mocked_buy_market_signal @pytest.fixture def mocked_buy_market_signal(): return signals.Signal( SIGNAL_TOPIC, { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.BUY.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.BUY_MARKET.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.356892%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 1001.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: "managed_order long entry (id: 143968020)", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "adc24701-573b-40dd-b6c9-3666cd22f33e", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], trading_enums.TradingSignalOrdersAttrs.ASSOCIATED_ORDER_IDS.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATE_WITH_TRIGGERING_ORDER_FEES.value: True, } ) ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/tests/test_remote_trading_signals_trading_consumer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import pytest import mock import octobot_trading.api as trading_api import octobot_commons.signals as commons_signals import octobot_trading.enums as trading_enums import octobot_trading.errors as errors import octobot_trading.constants as trading_constants import octobot_trading.personal_data as trading_personal_data import octobot_services.api as services_api import octobot_trading.modes.script_keywords as script_keywords from tentacles.Trading.Mode.remote_trading_signals_trading_mode.tests import local_trader, mocked_sell_limit_signal, \ mocked_bundle_stop_loss_in_sell_limit_in_market_signal, mocked_buy_market_signal, mocked_buy_limit_signal, \ mocked_update_leverage_signal, mocked_bundle_trigger_above_stop_loss_in_sell_limit_in_market_signal, \ mocked_bundle_trailing_stop_loss_in_sell_limit_in_market_signal, mocked_sell_limit_signal_with_trailing_group # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_internal_callback(local_trader, mocked_sell_limit_signal, mocked_update_leverage_signal): _, consumer, _ = local_trader consumer.logger = mock.Mock(info=mock.Mock(), error=mock.Mock(), exception=mock.Mock()) with mock.patch.object(consumer, "_handle_signal_orders", new=mock.AsyncMock()) \ as _handle_signal_orders_mock: await consumer.internal_callback( "trading_mode_name", "cryptocurrency", "symbol", "time_frame", "final_note", "state", mocked_sell_limit_signal ) _handle_signal_orders_mock.assert_called_once_with("symbol", mocked_sell_limit_signal) consumer.logger.info.assert_not_called() consumer.logger.error.assert_not_called() consumer.logger.exception.assert_not_called() with mock.patch.object(consumer, "_handle_positions_signal", new=mock.AsyncMock()) \ as _handle_positions_signal_mock: await consumer.internal_callback("trading_mode_name", "cryptocurrency", "symbol", "time_frame", "final_note", "state", mocked_update_leverage_signal) _handle_positions_signal_mock.assert_called_once_with("symbol", mocked_update_leverage_signal) consumer.logger.info.assert_not_called() consumer.logger.error.assert_not_called() consumer.logger.exception.assert_not_called() with mock.patch.object(consumer, "_handle_signal_orders", new=mock.AsyncMock(side_effect=errors.MissingMinimalExchangeTradeVolume)) \ as _handle_signal_orders_mock: await consumer.internal_callback("trading_mode_name", "cryptocurrency", "symbol/x", "time_frame", "final_note", "state", mocked_sell_limit_signal) _handle_signal_orders_mock.assert_called_once_with("symbol/x", mocked_sell_limit_signal) consumer.logger.info.assert_called_once() consumer.logger.error.assert_not_called() consumer.logger.exception.assert_not_called() consumer.logger.info.reset_mock() with mock.patch.object(consumer, "_handle_signal_orders", new=mock.AsyncMock(side_effect=RuntimeError)) \ as _handle_signal_orders_mock: await consumer.internal_callback("trading_mode_name", "cryptocurrency", "symbol/x", "time_frame", "final_note", "state", mocked_sell_limit_signal) _handle_signal_orders_mock.assert_called_once_with("symbol/x", mocked_sell_limit_signal) consumer.logger.info.assert_not_called() consumer.logger.error.assert_not_called() consumer.logger.exception.assert_called_once() with mock.patch.object(consumer, "_handle_signal_orders", new=mock.AsyncMock(side_effect=RuntimeError)) as _handle_signal_orders_mock, \ mock.patch.object(consumer, "_handle_positions_signal", new=mock.AsyncMock(side_effect=RuntimeError)) \ as _handle_positions_signal_mock: mocked_sell_limit_signal.topic = "plop" await consumer.internal_callback("trading_mode_name", "cryptocurrency", "symbol/x", "time_frame", "final_note", "state", mocked_sell_limit_signal) _handle_signal_orders_mock.assert_not_called() _handle_positions_signal_mock.assert_not_called() consumer.logger.info.assert_not_called() consumer.logger.error.assert_called_once() consumer.logger.exception.assert_called_once() async def test_handle_signal_orders(local_trader, mocked_bundle_stop_loss_in_sell_limit_in_market_signal): _, consumer, trader = local_trader symbol = mocked_bundle_stop_loss_in_sell_limit_in_market_signal.content[ trading_enums.TradingSignalOrdersAttrs.SYMBOL.value ] exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] assert consumer.trading_mode.last_signal_description == "" await consumer._handle_signal_orders(symbol, mocked_bundle_stop_loss_in_sell_limit_in_market_signal) # ensure orders are created orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 2 # market order is filled, chained & bundled orders got created assert isinstance(orders[0], trading_personal_data.StopLossOrder) assert isinstance(orders[0].order_group, trading_personal_data.BalancedTakeProfitAndStopOrderGroup) assert isinstance(orders[0].order_group.active_order_swap_strategy, trading_personal_data.StopFirstActiveOrderSwapStrategy) assert orders[0].order_group.active_order_swap_strategy.swap_timeout == 3 assert orders[0].order_group.active_order_swap_strategy.trigger_price_configuration == trading_enums.ActiveOrderSwapTriggerPriceConfiguration.FILLING_PRICE.value assert orders[0].trailing_profile is None assert orders[0].update_with_triggering_order_fees is False assert orders[0].origin_price == decimal.Decimal("9990") assert orders[0].trigger_above is False assert orders[0].is_active is True assert orders[0].active_trigger is None assert isinstance(orders[0].cancel_policy, trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy) assert isinstance(orders[1], trading_personal_data.SellLimitOrder) assert orders[1].order_group is orders[0].order_group assert orders[1].trailing_profile is None assert orders[1].is_active is False assert orders[1].active_trigger.trigger_price == decimal.Decimal(21) assert orders[1].active_trigger.trigger_above is False assert orders[1].update_with_triggering_order_fees is True assert orders[1].trigger_above is True assert orders[1].origin_quantity == decimal.Decimal("0.10713784") # initial quantity as assert orders[1].cancel_policy is None # update_with_triggering_order_fees is False trades = list(exchange_manager.exchange_personal_data.trades_manager.trades.values()) assert len(trades) == 1 assert trades[0].trade_type, trading_enums.TraderOrderType.BUY_MARKET assert trades[0].status is trading_enums.OrderStatus.FILLED assert "2" in consumer.trading_mode.last_signal_description # disable created order group so that changing their groups doesnt cancel them await orders[0].order_group.enable(False) # now edit, cancel orders and create a new one # change StopLossOrder group and cancel SellLimitOrder nested_edit_signal, cancel_signal, create_signal = _group_edit_cancel_create_order_signals( orders[0].order_id, "new_group_id", trading_personal_data.OneCancelsTheOtherOrderGroup.__name__, orders[0].order_id, "3.356892%", 2000, orders[1].order_id ) await consumer._handle_signal_orders(symbol, nested_edit_signal) await consumer._handle_signal_orders(symbol, cancel_signal) await consumer._handle_signal_orders(symbol, create_signal) # ensure orders are created orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 2 # market order is filled, chained & bundled orders got created assert isinstance(orders[0], trading_personal_data.StopLossOrder) assert isinstance(orders[0].order_group, trading_personal_data.OneCancelsTheOtherOrderGroup) # not balance group anymore assert orders[0].order_group.name == "new_group_id" assert orders[0].origin_quantity == decimal.Decimal("0.3392821050783528672") # changed quantity according to fees assert orders[0].origin_price == decimal.Decimal("2000") # changed price assert isinstance(orders[1], trading_personal_data.BuyLimitOrder) # not sell order (sell is cancelled) trades = list(exchange_manager.exchange_personal_data.trades_manager.trades.values()) assert len(trades) == 2 assert trades[0].trade_type, trading_enums.TraderOrderType.BUY_MARKET assert trades[1].trade_type, trading_enums.TraderOrderType.SellLimitOrder assert trades[1].status is trading_enums.OrderStatus.CANCELED assert "1" in consumer.trading_mode.last_signal_description async def test_handle_signal_orders_trailing_stop_with_cancel_policy( local_trader, mocked_bundle_trailing_stop_loss_in_sell_limit_in_market_signal ): _, consumer, trader = local_trader symbol = mocked_bundle_trailing_stop_loss_in_sell_limit_in_market_signal.content[ trading_enums.TradingSignalOrdersAttrs.SYMBOL.value ] exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] assert consumer.trading_mode.last_signal_description == "" await consumer._handle_signal_orders(symbol, mocked_bundle_trailing_stop_loss_in_sell_limit_in_market_signal) # ensure orders are created orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 2 # market order is filled, chained & bundled orders got created assert isinstance(orders[0], trading_personal_data.StopLossOrder) assert isinstance(orders[0].order_group, trading_personal_data.TrailingOnFilledTPBalancedOrderGroup) # trailing profile is restored assert orders[0].trailing_profile == trading_personal_data.FilledTakeProfitTrailingProfile([ trading_personal_data.TrailingPriceStep(price, price, True) for price in (10000, 12000, 13000) ]) assert orders[0].update_with_triggering_order_fees is False assert orders[0].origin_price == decimal.Decimal("9990") assert orders[0].trigger_above is False assert isinstance(orders[0].cancel_policy, trading_personal_data.ExpirationTimeOrderCancelPolicy) assert orders[0].cancel_policy.expiration_time == 1000.0 assert isinstance(orders[1], trading_personal_data.SellLimitOrder) assert orders[1].order_group is orders[0].order_group assert orders[1].trailing_profile is None assert orders[1].update_with_triggering_order_fees is True assert orders[1].trigger_above is True assert orders[1].origin_quantity == decimal.Decimal("0.10713784") # initial quantity as assert orders[1].cancel_policy is None # update_with_triggering_order_fees is False trades = list(exchange_manager.exchange_personal_data.trades_manager.trades.values()) assert len(trades) == 1 assert trades[0].trade_type, trading_enums.TraderOrderType.BUY_MARKET assert trades[0].status is trading_enums.OrderStatus.FILLED assert "2" in consumer.trading_mode.last_signal_description async def test_handle_signal_orders_trigger_above_stop_loss(local_trader, mocked_bundle_trigger_above_stop_loss_in_sell_limit_in_market_signal): _, consumer, trader = local_trader symbol = mocked_bundle_trigger_above_stop_loss_in_sell_limit_in_market_signal.content[ trading_enums.TradingSignalOrdersAttrs.SYMBOL.value ] exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] assert consumer.trading_mode.last_signal_description == "" await consumer._handle_signal_orders(symbol, mocked_bundle_trigger_above_stop_loss_in_sell_limit_in_market_signal) # ensure orders are created orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 2 # market order is filled, chained & bundled orders got created assert isinstance(orders[0], trading_personal_data.StopLossOrder) assert isinstance(orders[0].order_group, trading_personal_data.BalancedTakeProfitAndStopOrderGroup) assert orders[0].update_with_triggering_order_fees is False assert orders[0].origin_price == decimal.Decimal("999999990") assert orders[0].trigger_above is True assert isinstance(orders[1], trading_personal_data.SellLimitOrder) assert orders[1].order_group is orders[0].order_group assert orders[1].update_with_triggering_order_fees is True assert orders[1].trigger_above is True assert orders[1].origin_quantity == decimal.Decimal("0.10713784") # initial quantity as # update_with_triggering_order_fees is False trades = list(exchange_manager.exchange_personal_data.trades_manager.trades.values()) assert len(trades) == 1 assert trades[0].trade_type, trading_enums.TraderOrderType.BUY_MARKET assert trades[0].status is trading_enums.OrderStatus.FILLED assert "2" in consumer.trading_mode.last_signal_description async def test_handle_signal_orders_no_triggering_order( local_trader, mocked_bundle_stop_loss_in_sell_limit_in_market_signal ): _, consumer, trader = local_trader symbol = mocked_bundle_stop_loss_in_sell_limit_in_market_signal.content[ trading_enums.TradingSignalOrdersAttrs.SYMBOL.value ] exchange_manager = trader.exchange_manager await consumer._handle_signal_orders(symbol, mocked_bundle_stop_loss_in_sell_limit_in_market_signal) # ensure orders are created orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 2 # market order is filled, chained & bundled orders got created # same as test_handle_signal_orders: skip other asserts assert orders[1].order_group is orders[0].order_group assert orders[0].order_id in exchange_manager.exchange_personal_data.orders_manager.\ get_all_active_and_pending_orders_id() assert orders[1].order_id in exchange_manager.exchange_personal_data.orders_manager.\ get_all_active_and_pending_orders_id() # now edit, cancel orders and create a new one # change StopLossOrder group and cancel SellLimitOrder _, cancel_signal, _ = _group_edit_cancel_create_order_signals( orders[0].order_id, "new_group_id", trading_personal_data.OneCancelsTheOtherOrderGroup.__name__, orders[0].order_id, "3.356892%", 2000, orders[1].order_id ) cancel_signal.content[trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value] = "0" await consumer._handle_signal_orders(symbol, cancel_signal) port_cancel_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() # order 1 got cancelled, since it's grouped with order 0, both are cancelled assert len(port_cancel_orders) ==0 assert orders[0].order_id not in exchange_manager.exchange_personal_data.orders_manager.\ get_all_active_and_pending_orders_id() assert orders[1].order_id not in exchange_manager.exchange_personal_data.orders_manager.\ get_all_active_and_pending_orders_id() async def test_handle_signal_orders_reduce_quantity_create_order(local_trader, mocked_buy_market_signal): _, consumer, trader = local_trader symbol = mocked_buy_market_signal.content[ trading_enums.TradingSignalOrdersAttrs.SYMBOL.value ] mocked_buy_market_signal.content[trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value] = "75%" mocked_buy_market_signal.content[trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value] = None exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] assert consumer.trading_mode.last_signal_description == "" await consumer._handle_signal_orders(symbol, mocked_buy_market_signal) # market order is filled, chained & bundled orders got created orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 0 trades = list(exchange_manager.exchange_personal_data.trades_manager.trades.values()) assert len(trades) == 1 assert trades[0].trade_type, trading_enums.TraderOrderType.BUY_MARKET # used 75% of funds # can buy max 2, should buy 1.5, buy one because of config assert trades[0].origin_quantity == decimal.Decimal("1") assert trades[0].origin_price == decimal.Decimal("1000") async def test_handle_signal_orders_reduce_quantity_edit_order(local_trader, mocked_buy_limit_signal): _, consumer, trader = local_trader symbol = mocked_buy_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.SYMBOL.value] trading_api.force_set_mark_price(trader.exchange_manager, "BTC/USDT:USDT", 1000) edit_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.EDIT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: None, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: symbol, trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: 0, trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: "80%", trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: None, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: None, trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: mocked_buy_limit_signal.content[ trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value ], trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], }, ) exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] assert consumer.trading_mode.last_signal_description == "" await consumer._handle_signal_orders(symbol, mocked_buy_limit_signal) orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 1 assert orders[0].origin_quantity == decimal.Decimal("0.10714817") await consumer._handle_signal_orders(symbol, edit_signal) orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 1 assert orders[0].origin_quantity == decimal.Decimal("1") # use 50% max as quantity (vs 80% in signal) async def test_handle_signal_create_orders_not_enough_funds_using_min_amount(local_trader, mocked_buy_limit_signal): _, consumer, trader = local_trader symbol = mocked_buy_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.SYMBOL.value] trading_api.force_set_mark_price(trader.exchange_manager, "BTC/USDT:USDT", 1000) # too small amount for the current porfolio to handle within exchange rules amount = "0.00000001%" limit_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: symbol, trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.SELL_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: amount, trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 20898.03, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 20600.31, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "98ea73a0-ed38-4fca-9744-ed7f80a2d3ef", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.OneCancelsTheOtherOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TAG.value: None, trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "12e7ad8f-10a1-4cd3-bf86-d972226bd079", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], }, ) consumer.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = True exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] await consumer._handle_signal_orders(symbol, limit_signal) orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 1 order_1 = orders[0] assert order_1.origin_quantity == decimal.Decimal("0.00001") # minimal amount according to exchange rules consumer.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = False # now disable minimal amount config await consumer._handle_signal_orders(symbol, limit_signal) orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 1 assert orders[0] is order_1 # no order created consumer.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = True # re-enable minimal amount config # same order id: no order created await consumer._handle_signal_orders(symbol, limit_signal) orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 1 assert orders[0] is order_1 # no order created # change order id not to skip creation limit_signal.content[trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value] = "123" await consumer._handle_signal_orders(symbol, limit_signal) orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert len(orders) == 2 assert orders[0] is order_1 # no order created assert orders[1].origin_quantity == decimal.Decimal("0.00001") # minimal amount according to exchange rules async def test_handle_signal_create_orders_not_enough_available_funds_even_for_min_order(local_trader, mocked_buy_limit_signal): _, consumer, trader = local_trader symbol = "BTC/USDT:USDT" trading_api.force_set_mark_price(trader.exchange_manager, "BTC/USDT:USDT", 1000) trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["BTC"].available = trading_constants.ZERO limit_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: symbol, trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.SELL_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "39.5865%a", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 20898.03, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 20600.31, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "98ea73a0-ed38-4fca-9744-ed7f80a2d3ef", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.OneCancelsTheOtherOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TAG.value: None, trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "12e7ad8f-10a1-4cd3-bf86-d972226bd079", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], }, ) consumer.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = True exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] await consumer._handle_signal_orders(symbol, limit_signal) assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] async def test_handle_signal_create_orders_not_enough_total_funds_even_for_min_order(local_trader, mocked_buy_limit_signal): _, consumer, trader = local_trader symbol = "BTC/USDT:USDT" trading_api.force_set_mark_price(trader.exchange_manager, "BTC/USDT:USDT", 1000) trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["BTC"].total = trading_constants.ZERO limit_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: symbol, trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.SELL_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "39.5865%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 20898.03, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 20600.31, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: "98ea73a0-ed38-4fca-9744-ed7f80a2d3ef", trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: trading_personal_data.OneCancelsTheOtherOrderGroup.__name__, trading_enums.TradingSignalOrdersAttrs.TAG.value: None, trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "12e7ad8f-10a1-4cd3-bf86-d972226bd079", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], }, ) consumer.ROUND_TO_MINIMAL_SIZE_IF_NECESSARY = True exchange_manager = trader.exchange_manager assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] await consumer._handle_signal_orders(symbol, limit_signal) assert exchange_manager.exchange_personal_data.orders_manager.get_open_orders() == [] async def test_send_alert_notification(local_trader): _, consumer, _ = local_trader with mock.patch.object(services_api, "send_notification", mock.AsyncMock()) as send_notification_mock: await consumer._send_alert_notification("BTC/USDT:USDT", 42, 62, 78) send_notification_mock.assert_called_once() notification = send_notification_mock.mock_calls[0].args[0] assert all(str(counter) in notification.text for counter in (42, 62, 78)) send_notification_mock.reset_mock() await consumer._send_alert_notification("BTC/USDT:USDT", 0, 0, 99) send_notification_mock.assert_called_once() notification = send_notification_mock.mock_calls[0].args[0] assert "99" in notification.text assert "0" not in notification.text # TODO add more unit hedge case tests when arch is validated def _group_edit_cancel_create_order_signals(to_group_id, group_id, group_type, to_edit_id, to_edit_target_amount, to_edit_price, to_cancel_id): nested_edit_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.ADD_TO_GROUP.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.BUY_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.3574085830652285%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 800.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: group_id, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: group_type, trading_enums.TradingSignalOrdersAttrs.TAG.value: "second wave order", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: to_group_id, trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [ { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.EDIT.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: None, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: 0, trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: to_edit_target_amount, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: to_edit_price, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: None, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: None, trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: to_edit_id, trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], }, ], }, ) cancel_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CANCEL.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: None, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: 0, trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: None, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: None, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: None, trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: to_cancel_id, trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], }, ) create_signal = commons_signals.Signal( "moonmoon", { trading_enums.TradingSignalCommonsAttrs.ACTION.value: trading_enums.TradingSignalOrdersActions.CREATE.value, trading_enums.TradingSignalOrdersAttrs.SIDE.value: trading_enums.TradeOrderSide.SELL.value, trading_enums.TradingSignalOrdersAttrs.SYMBOL.value: "BTC/USDT:USDT", trading_enums.TradingSignalOrdersAttrs.EXCHANGE.value: "bybit", trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value: trading_enums.ExchangeTypes.SPOT.value, trading_enums.TradingSignalOrdersAttrs.TYPE.value: trading_enums.TraderOrderType.BUY_LIMIT.value, trading_enums.TradingSignalOrdersAttrs.QUANTITY.value: 0.004, trading_enums.TradingSignalOrdersAttrs.TARGET_AMOUNT.value: "5.3574085830652285%", trading_enums.TradingSignalOrdersAttrs.TARGET_POSITION.value: 0, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_AMOUNT.value: None, trading_enums.TradingSignalOrdersAttrs.UPDATED_TARGET_POSITION.value: None, trading_enums.TradingSignalOrdersAttrs.LIMIT_PRICE.value: 800.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_LIMIT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.UPDATED_STOP_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.CURRENT_PRICE.value: 1000.69, trading_enums.TradingSignalOrdersAttrs.UPDATED_CURRENT_PRICE.value: 0.0, trading_enums.TradingSignalOrdersAttrs.REDUCE_ONLY.value: True, trading_enums.TradingSignalOrdersAttrs.POST_ONLY.value: False, trading_enums.TradingSignalOrdersAttrs.GROUP_ID.value: None, trading_enums.TradingSignalOrdersAttrs.GROUP_TYPE.value: None, trading_enums.TradingSignalOrdersAttrs.TAG.value: "second wave order", trading_enums.TradingSignalOrdersAttrs.ORDER_ID.value: "aaaa-f970-45d9-9ba8-f63da17f17ba", trading_enums.TradingSignalOrdersAttrs.BUNDLED_WITH.value: None, trading_enums.TradingSignalOrdersAttrs.CHAINED_TO.value: None, trading_enums.TradingSignalOrdersAttrs.ADDITIONAL_ORDERS.value: [], } ) return nested_edit_signal, cancel_signal, create_signal async def test_handle_positions_signal(local_trader, mocked_update_leverage_signal): _, consumer, trader = local_trader symbol = mocked_update_leverage_signal.content[ trading_enums.TradingSignalPositionsAttrs.SYMBOL.value ] with mock.patch.object(consumer, "_edit_position", mock.AsyncMock()) as _edit_position_mock: await consumer._handle_positions_signal(symbol, mocked_update_leverage_signal) _edit_position_mock.assert_called_once_with(symbol, mocked_update_leverage_signal) _edit_position_mock.reset_mock() # unknown action mocked_update_leverage_signal.content[trading_enums.TradingSignalCommonsAttrs.ACTION.value] = "plop" await consumer._handle_positions_signal(symbol, mocked_update_leverage_signal) _edit_position_mock.assert_not_called() async def test_edit_position(local_trader, mocked_update_leverage_signal): _, consumer, trader = local_trader trader.exchange_manager.is_future = False symbol = mocked_update_leverage_signal.content[ trading_enums.TradingSignalPositionsAttrs.SYMBOL.value ] with mock.patch.object(trader, "set_leverage", mock.AsyncMock()) as set_leverage_mock: leverage = mocked_update_leverage_signal.content[ trading_enums.TradingSignalPositionsAttrs.LEVERAGE.value ] await consumer._handle_positions_signal(symbol, mocked_update_leverage_signal) set_leverage_mock.assert_not_called() trader.exchange_manager.is_future = True await consumer._handle_positions_signal(symbol, mocked_update_leverage_signal) set_leverage_mock.assert_called_once_with(symbol, None, decimal.Decimal(str(leverage))) set_leverage_mock.reset_mock() mocked_update_leverage_signal.content[ trading_enums.TradingSignalPositionsAttrs.SIDE.value ] = trading_enums.PositionSide.LONG.value await consumer._handle_positions_signal(symbol, mocked_update_leverage_signal) set_leverage_mock.assert_called_once_with(symbol, trading_enums.PositionSide.LONG, decimal.Decimal(str(leverage))) # do not propagate errors with mock.patch.object(trader, "set_leverage", mock.AsyncMock(side_effect=NotImplementedError)) as set_leverage_mock: await consumer._handle_positions_signal(symbol, mocked_update_leverage_signal) set_leverage_mock.assert_called_once() ================================================ FILE: Trading/Mode/remote_trading_signals_trading_mode/tests/test_remote_trading_signals_trading_producer.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import mock import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums from tentacles.Trading.Mode.remote_trading_signals_trading_mode.tests import local_trader, \ mocked_bundle_stop_loss_in_sell_limit_signal, mocked_sell_limit_signal, mocked_update_leverage_signal # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def test_signal_callback(local_trader, mocked_bundle_stop_loss_in_sell_limit_signal, mocked_update_leverage_signal): producer, _, _ = local_trader with mock.patch.object(producer, "submit_trading_evaluation", new=mock.AsyncMock()) \ as submit_trading_evaluation_mock: await producer.signal_callback(mocked_bundle_stop_loss_in_sell_limit_signal) submit_trading_evaluation_mock.assert_called_once_with( cryptocurrency=producer.trading_mode.cryptocurrency, symbol=mocked_bundle_stop_loss_in_sell_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.SYMBOL.value], time_frame=None, final_note=trading_constants.ZERO, state=trading_enums.EvaluatorStates.UNKNOWN, data=mocked_bundle_stop_loss_in_sell_limit_signal ) submit_trading_evaluation_mock.reset_mock() # with incompatible exchange type mocked_bundle_stop_loss_in_sell_limit_signal.content[trading_enums.TradingSignalOrdersAttrs.EXCHANGE_TYPE.value] = trading_enums.ExchangeTypes.MARGIN.value await producer.signal_callback(mocked_bundle_stop_loss_in_sell_limit_signal) submit_trading_evaluation_mock.assert_not_called() producer.exchange_manager.is_future = True await producer.signal_callback(mocked_update_leverage_signal) submit_trading_evaluation_mock.assert_called_once_with( cryptocurrency=producer.trading_mode.cryptocurrency, symbol=mocked_update_leverage_signal.content[trading_enums.TradingSignalPositionsAttrs.SYMBOL.value], time_frame=None, final_note=trading_constants.ZERO, state=trading_enums.EvaluatorStates.UNKNOWN, data=mocked_update_leverage_signal ) submit_trading_evaluation_mock.reset_mock() ================================================ FILE: Trading/Mode/signal_trading_mode/__init__.py ================================================ from .signal_trading import SignalTradingMode ================================================ FILE: Trading/Mode/signal_trading_mode/config/SignalTradingMode.json ================================================ { "close_to_current_price_difference": 0.02, "required_strategies": [ "MoveSignalsStrategyEvaluator" ], "max_currency_percent": 100, "use_maximum_size_orders": false, "use_prices_close_to_current_price": false, "use_stop_orders": true } ================================================ FILE: Trading/Mode/signal_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["SignalTradingMode"], "tentacles-requirements": ["move_signals_strategy_evaluator"] } ================================================ FILE: Trading/Mode/signal_trading_mode/resources/SignalTradingMode.md ================================================ SignalTradingMode is a trading mode adapted to liquid and relatively flat markets. It will try to find reversals and trade them. This trading mode is using the daily trading mode orders system with adapted parameters. Warning: SignalTradingMode only works on liquid markets because the [Klinger Oscillator](https://www.investopedia.com/terms/k/klingeroscillator.asp) from MoveSignalsStrategyEvaluator the requires enough volume and candles continuity to be accurate. ================================================ FILE: Trading/Mode/signal_trading_mode/signal_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import tentacles.Trading.Mode.daily_trading_mode.daily_trading as daily_trading_mode class SignalTradingMode(daily_trading_mode.DailyTradingMode): @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_current_state(self) -> (str, float): return super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, \ self.producers[0].final_eval def get_mode_producer_classes(self) -> list: return [SignalTradingModeProducer] def get_mode_consumer_classes(self) -> list: return [SignalTradingModeConsumer] @classmethod def get_is_symbol_wildcard(cls) -> bool: return False class SignalTradingModeConsumer(daily_trading_mode.DailyTradingModeConsumer): def __init__(self, trading_mode): super().__init__(trading_mode) self.STOP_LOSS_ORDER_MAX_PERCENT = decimal.Decimal(str(0.99)) self.STOP_LOSS_ORDER_MIN_PERCENT = decimal.Decimal(str(0.95)) self.QUANTITY_MIN_PERCENT = decimal.Decimal(str(0.1)) self.QUANTITY_MAX_PERCENT = decimal.Decimal(str(0.9)) self.QUANTITY_MARKET_MIN_PERCENT = decimal.Decimal(str(0.5)) self.QUANTITY_MARKET_MAX_PERCENT = trading_constants.ONE self.QUANTITY_BUY_MARKET_ATTENUATION = decimal.Decimal(str(0.2)) self.BUY_LIMIT_ORDER_MAX_PERCENT = decimal.Decimal(str(0.995)) self.BUY_LIMIT_ORDER_MIN_PERCENT = decimal.Decimal(str(0.99)) class SignalTradingModeProducer(daily_trading_mode.DailyTradingModeProducer): def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) # If final_eval not is < X_THRESHOLD --> state = X self.VERY_LONG_THRESHOLD = decimal.Decimal(str(-0.88)) self.LONG_THRESHOLD = decimal.Decimal(str(-0.4)) self.NEUTRAL_THRESHOLD = decimal.Decimal(str(0.4)) self.SHORT_THRESHOLD = decimal.Decimal(str(0.88)) self.RISK_THRESHOLD = decimal.Decimal(str(0.15)) ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/__init__.py ================================================ from .staggered_orders_trading import StaggeredOrdersTradingMode ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/config/StaggeredOrdersTradingMode.json ================================================ { "required_strategies": [], "pair_settings": [ { "pair": "BTC/USDT", "mode": "mountain", "spread_percent": 6, "increment_percent": 3, "lower_bound": 3000, "upper_bound": 6000, "allow_instant_fill": true, "operational_depth": 100, "mirror_order_delay": 0, "ignore_exchange_fees": false, "use_existing_orders_only": false }, { "pair": "ADA/ETH", "mode": "mountain", "spread_percent": 6, "increment_percent": 3, "lower_bound": 0.0003, "upper_bound": 0.0007, "allow_instant_fill": true, "operational_depth": 50, "mirror_order_delay": 0, "ignore_exchange_fees": false, "use_existing_orders_only": false }, { "pair": "ETH/USDT", "mode": "mountain", "spread_percent": 0.7, "increment_percent": 0.3, "lower_bound": 400, "upper_bound": 500, "allow_instant_fill": true, "operational_depth": 50, "mirror_order_delay": 0, "ignore_exchange_fees": false, "use_existing_orders_only": false } ] } ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["StaggeredOrdersTradingMode"], "tentacles-requirements": [] } ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/resources/StaggeredOrdersTradingMode.md ================================================ StaggeredOrdersTrading is an advanced version of the GridTradingMode. It places a large amount of buy and sell orders at fixed intervals, covering the order book from very low prices to very high prices in a grid like fashion. The range (defined by lower & upper bounds) is supposed to cover all conceivable prices for as long as the user intends to run the strategy, and this for each traded pair. That could be from -100x to +100x (-99% to +10000%). Note: the larger the covered range, the more orders and funds are required to execute the strategy. Profits will be made from price movements within the covered price area. It never "sells at a loss", but always at a profit, therefore OctoBot never cancels any orders when using the Staggered Orders Trading Mode. To know more, checkout the full Staggered Orders trading mode guide. #### Changing configuration To apply changes to the Staggered Orders Trading Mode settings, you will have to manually cancel orders and restart your OctoBot. This trading mode instantly places opposite side orders when an order is filled. OctoBot also performs a check every 3 days to ensure the grid healthy state and create missing grid orders if any. #### Traded pairs Only works with independent bases and quotes : ETH/USDT and ADA/BTC can be activated together but ETH/USDT and BTC/USDT can't be activated together for the same OctoBot instance since they are sharing the same symbol (here USDT). #### Funds allocation Staggered modes can be used to specify the way to allocate funds: modes are neutral, mountain, valley, sell slope and buy slope. _This trading mode supports PNL history._ ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/staggered_orders_trading.py ================================================ # pylint: disable=E701 # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import collections import enum import dataclasses import math import asyncio import decimal import typing import async_channel.constants as channel_constants import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.data_util as data_util import octobot_commons.signals as commons_signals import octobot_trading.api as trading_api import octobot_trading.modes as trading_modes import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.personal_data as trading_personal_data import octobot_trading.errors as trading_errors import octobot_trading.exchanges.util as exchange_util import octobot_trading.signals as signals class StrategyModes(enum.Enum): NEUTRAL = "neutral" MOUNTAIN = "mountain" VALLEY = "valley" SELL_SLOPE = "sell slope" BUY_SLOPE = "buy slope" FLAT = "flat" class ForceResetOrdersException(Exception): pass class TrailingAborted(Exception): pass class NoOrdersToTrail(Exception): pass INCREASING = "increasing_towards_current_price" DECREASING = "decreasing_towards_current_price" STABLE = "stable_towards_current_price" MULTIPLIER = "multiplier" MAX_TRAILING_PROCESS_DURATION = 5 * commons_constants.MINUTE_TO_SECONDS # enough to cancel & re-create orders ONE_PERCENT_DECIMAL = decimal.Decimal("1.01") TEN_PERCENT_DECIMAL = decimal.Decimal("1.1") CREATED_ORDER_AVAILABLE_FUNDS_ALLOWED_RATIO = decimal.Decimal("0.97") StrategyModeMultipliersDetails = { StrategyModes.FLAT: { MULTIPLIER: trading_constants.ZERO, trading_enums.TradeOrderSide.BUY: STABLE, trading_enums.TradeOrderSide.SELL: STABLE }, StrategyModes.NEUTRAL: { MULTIPLIER: decimal.Decimal("0.3"), trading_enums.TradeOrderSide.BUY: INCREASING, trading_enums.TradeOrderSide.SELL: INCREASING }, StrategyModes.MOUNTAIN: { MULTIPLIER: trading_constants.ONE, trading_enums.TradeOrderSide.BUY: INCREASING, trading_enums.TradeOrderSide.SELL: INCREASING }, StrategyModes.VALLEY: { MULTIPLIER: trading_constants.ONE, trading_enums.TradeOrderSide.BUY: DECREASING, trading_enums.TradeOrderSide.SELL: DECREASING }, StrategyModes.BUY_SLOPE: { MULTIPLIER: trading_constants.ONE, trading_enums.TradeOrderSide.BUY: DECREASING, trading_enums.TradeOrderSide.SELL: INCREASING }, StrategyModes.SELL_SLOPE: { MULTIPLIER: trading_constants.ONE, trading_enums.TradeOrderSide.BUY: INCREASING, trading_enums.TradeOrderSide.SELL: DECREASING } } @dataclasses.dataclass class OrderData: side: trading_enums.TradeOrderSide = None quantity: decimal.Decimal = trading_constants.ZERO price: decimal.Decimal = trading_constants.ZERO symbol: str = 0 is_virtual: bool = True associated_entry_id: str = None class StaggeredOrdersTradingMode(trading_modes.AbstractTradingMode): CONFIG_PAIR_SETTINGS = "pair_settings" CONFIG_PAIR = "pair" CONFIG_MODE = "mode" CONFIG_SPREAD = "spread_percent" CONFIG_INCREMENT_PERCENT = "increment_percent" CONFIG_LOWER_BOUND = "lower_bound" CONFIG_UPPER_BOUND = "upper_bound" CONFIG_USE_EXISTING_ORDERS_ONLY = "use_existing_orders_only" CONFIG_ALLOW_INSTANT_FILL = "allow_instant_fill" CONFIG_OPERATIONAL_DEPTH = "operational_depth" CONFIG_MIRROR_ORDER_DELAY = "mirror_order_delay" CONFIG_ALLOW_FUNDS_REDISPATCH = "allow_funds_redispatch" CONFIG_ENABLE_TRAILING_UP = "enable_trailing_up" CONFIG_ENABLE_TRAILING_DOWN = "enable_trailing_down" CONFIG_ORDER_BY_ORDER_TRAILING = "order_by_order_trailing" CONFIG_FUNDS_REDISPATCH_INTERVAL = "funds_redispatch_interval" COMPENSATE_FOR_MISSED_MIRROR_ORDER = "compensate_for_missed_mirror_order" CONFIG_STARTING_PRICE = "starting_price" CONFIG_BUY_FUNDS = "buy_funds" CONFIG_SELL_FUNDS = "sell_funds" CONFIG_SELL_VOLUME_PER_ORDER = "sell_volume_per_order" CONFIG_BUY_VOLUME_PER_ORDER = "buy_volume_per_order" CONFIG_IGNORE_EXCHANGE_FEES = "ignore_exchange_fees" ENABLE_UPWARDS_PRICE_FOLLOW = "enable_upwards_price_follow" CONFIG_DEFAULT_SPREAD_PERCENT = 1.5 CONFIG_DEFAULT_INCREMENT_PERCENT = 0.5 REQUIRE_TRADES_HISTORY = True # set True when this trading mode needs the trade history to operate SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION = True # set True when self._optimize_initial_portfolio is implemented SUPPORTS_HEALTH_CHECK = False # set True when self.health_check is implemented def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.UI.user_input(self.CONFIG_PAIR_SETTINGS, commons_enums.UserInputTypes.OBJECT_ARRAY, self.trading_config.get(self.CONFIG_PAIR_SETTINGS, None), inputs, item_title="Pair configuration", other_schema_values={"minItems": 1, "uniqueItems": True}, title="Configuration for each traded pairs.") self.UI.user_input(self.CONFIG_PAIR, commons_enums.UserInputTypes.TEXT, "BTC/USDT", inputs, other_schema_values={"minLength": 3, "pattern": commons_constants.TRADING_SYMBOL_REGEX}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Name of the traded pair."), self.UI.user_input( self.CONFIG_MODE, commons_enums.UserInputTypes.OPTIONS, StrategyModes.NEUTRAL.value, inputs, options=list(mode.value for mode in StrategyModes), parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Mode: way to allocate funds in created orders.", ) self.UI.user_input( self.CONFIG_SPREAD, commons_enums.UserInputTypes.FLOAT, self.CONFIG_DEFAULT_SPREAD_PERCENT, inputs, min_val=0, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Spread: price difference between buy and sell orders: percent of the current price to use as " "spread (difference between highest buy and lowest sell). " "Example: enter 10 to use 10% of the current price as spread.", ) self.UI.user_input( self.CONFIG_INCREMENT_PERCENT, commons_enums.UserInputTypes.FLOAT, self.CONFIG_DEFAULT_INCREMENT_PERCENT, inputs, min_val=0, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Increment: price difference between grid orders: percent of the current price to use as increment " "between orders. Example: enter 3 to use 3% of the current price as increment. " "WARNING: this should be lower than the Spread value: profitability is close to " "Spread-Increment.", ) self.UI.user_input( self.CONFIG_LOWER_BOUND, commons_enums.UserInputTypes.FLOAT, 0.005, inputs, min_val=0, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Lower bound: lower limit of the grid: minimum price to start placing buy orders from: lower " "limit of the grid. " "Example: a lower bound of 0.2 will create a grid covering a price down to 0.2." ) self.UI.user_input( self.CONFIG_UPPER_BOUND, commons_enums.UserInputTypes.FLOAT, 0.005, inputs, min_val=0, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Upper bound: upper limit of the grid: maximum price to stop placing sell orders. " "Example: an upper bound of 1000 will create a grid covering up to a price for 1000.", ) self.UI.user_input( self.CONFIG_OPERATIONAL_DEPTH, commons_enums.UserInputTypes.INT, 50, inputs, min_val=1, other_schema_values={"exclusiveMinimum": True}, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Operational depth: maximum number of orders to be maintained on exchange.", ) self.UI.user_input( self.CONFIG_MIRROR_ORDER_DELAY, commons_enums.UserInputTypes.FLOAT, 0, inputs, min_val=0, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="[Optional] Mirror order delay: Seconds to wait for before creating a mirror order when an order " "is filled. This can generate extra profits on quick market moves.", ) self.UI.user_input( self.CONFIG_IGNORE_EXCHANGE_FEES, commons_enums.UserInputTypes.BOOLEAN, True, inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Ignore exchange fees: when checked, exchange fees won't be considered when creating mirror orders. " "When unchecked, a part of the total volume will be reduced to take exchange " "fees into account.", ) self.UI.user_input( self.CONFIG_USE_EXISTING_ORDERS_ONLY, commons_enums.UserInputTypes.BOOLEAN, False, inputs, parent_input_name=self.CONFIG_PAIR_SETTINGS, title="Use existing orders only: when checked, new orders will only be created upon pre-existing orders " "fill. OctoBot won't create orders at startup: it will use the ones already on exchange instead. " "This mode allows staggered orders to operate on user created orders. " "Can't work on trading simulator.", ) def get_current_state(self) -> (str, float): order = self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(self.symbol) sell_count = len([o for o in order if o.side == trading_enums.TradeOrderSide.SELL]) buy_count = len(order) - sell_count if buy_count > sell_count: state = trading_enums.EvaluatorStates.LONG elif buy_count < sell_count: state = trading_enums.EvaluatorStates.SHORT else: state = trading_enums.EvaluatorStates.NEUTRAL return state.name, f"{buy_count} buy {sell_count} sell" def get_mode_producer_classes(self) -> list: return [StaggeredOrdersTradingModeProducer] def get_mode_consumer_classes(self) -> list: return [StaggeredOrdersTradingModeConsumer] async def create_consumers(self) -> list: consumers = await super().create_consumers() # order consumer: filter by symbol not be triggered only on this symbol's orders order_consumer = await exchanges_channel.get_chan(trading_personal_data.OrdersChannel.get_name(), self.exchange_manager.id).new_consumer( self._order_notification_callback, symbol=self.symbol if self.symbol else channel_constants.CHANNEL_WILDCARD ) return consumers + [order_consumer] async def _order_notification_callback(self, exchange, exchange_id, cryptocurrency, symbol, order, update_type, is_from_bot): if ( order[trading_enums.ExchangeConstantsOrderColumns.STATUS.value] == trading_enums.OrderStatus.FILLED.value and order[trading_enums.ExchangeConstantsOrderColumns.TYPE.value] in ( trading_enums.TradeOrderType.LIMIT.value ) and is_from_bot ): await self.producers[0].order_filled_callback(order) @classmethod def get_is_symbol_wildcard(cls) -> bool: return False def set_default_config(self): raise RuntimeError(f"Impossible to start {self.get_name()} without a valid configuration file.") async def single_exchange_process_health_check(self, chained_orders: list, tickers: dict) -> list: created_orders = [] if await self._should_rebalance_orders(): target_asset = exchange_util.get_common_traded_quote(self.exchange_manager) created_orders += await self.single_exchange_process_optimize_initial_portfolio([], target_asset, tickers) for producer in self.producers: await producer.trigger_staggered_orders_creation() return created_orders async def _should_rebalance_orders(self): for producer in self.producers: if producer.enable_upwards_price_follow: # trigger rebalance when current price is beyond the highest sell order if await producer.is_price_beyond_boundaries(): return True return False async def single_exchange_process_optimize_initial_portfolio( self, sellable_assets: list, target_asset: str, tickers: dict ) -> list: portfolio = self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio producer = self.producers[0] pair_bases = set() # 1. cancel open orders try: cancelled_orders, part_1_dependencies = await self._cancel_associated_orders(producer, pair_bases) except Exception as err: self.logger.exception(err, True, f"Error during portfolio optimization cancel orders step: {err}") cancelled_orders = [] # 2. convert assets to sell funds into target assets try: part_1_orders = await self._convert_assets_into_target( producer, pair_bases, target_asset, set(sellable_assets), tickers, part_1_dependencies ) except Exception as err: self.logger.exception( err, True, f"Error during portfolio optimization convert into target step: {err}" ) part_1_orders = [] # 3. compute necessary funds for each configured_pairs converted_quote_amount_per_symbol = self._get_converted_quote_amount_per_symbol( portfolio, pair_bases, target_asset ) # 4. buy assets if converted_quote_amount_per_symbol == trading_constants.ZERO: self.logger.warning(f"No {target_asset} in portfolio after optimization.") part_2_orders = [] else: part_2_dependencies = signals.get_orders_dependencies(part_1_orders) part_2_orders = await self._buy_assets( producer, pair_bases, target_asset, converted_quote_amount_per_symbol, tickers, part_2_dependencies ) return [cancelled_orders, part_1_orders, part_2_orders] async def _cancel_associated_orders( self, producer, pair_bases ) -> tuple[list, typing.Optional[commons_signals.SignalDependencies]]: cancelled_orders = [] dependencies = commons_signals.SignalDependencies() self.logger.info(f"Optimizing portfolio: cancelling existing open orders on " f"{self.exchange_manager.exchange_config.traded_symbol_pairs}") for symbol in self.exchange_manager.exchange_config.traded_symbol_pairs: if producer.get_symbol_trading_config(symbol) is not None: pair_bases.add(symbol_util.parse_symbol(symbol).base) for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders( symbol=symbol ): if not (order.is_cancelled() or order.is_closed()): cancelled, dependency = await self.cancel_order(order) if cancelled: dependencies.extend(dependency) cancelled_orders.append(order) return cancelled_orders, (dependencies or None) async def _convert_assets_into_target( self, producer, pair_bases, common_quote, to_sell_assets, tickers, dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> list: to_sell_assets = to_sell_assets.union(pair_bases) self.logger.info(f"Optimizing portfolio: selling {to_sell_assets} to buy {common_quote}") # need portfolio available to be up-to-date with cancelled orders orders = await trading_modes.convert_assets_to_target_asset( self, list(to_sell_assets), common_quote, tickers, dependencies=dependencies ) if orders: await asyncio.gather( *[ trading_personal_data.wait_for_order_fill( order, producer.MISSING_MIRROR_ORDERS_MARKET_REBALANCE_TIMEOUT, True ) for order in orders ] ) return orders async def _buy_assets( self, producer, pair_bases, common_quote, converted_quote_amount_per_symbol, tickers, dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> list: created_orders = [] for base in pair_bases: self.logger.info( f"Optimizing portfolio: buying {base} with " f"{float(converted_quote_amount_per_symbol)} {common_quote}" ) try: created_orders += await trading_modes.convert_asset_to_target_asset( self, common_quote, base, tickers, asset_amount=converted_quote_amount_per_symbol, dependencies=dependencies ) except Exception as err: self.logger.exception(err, True, f"Error when creating order to buy {base}: {err}") if created_orders: await asyncio.gather( *[ trading_personal_data.wait_for_order_fill( order, producer.MISSING_MIRROR_ORDERS_MARKET_REBALANCE_TIMEOUT, True ) for order in created_orders ] ) return created_orders def _get_converted_quote_amount_per_symbol(self, portfolio, pair_bases, common_quote) -> decimal.Decimal: trading_pairs_count = len(pair_bases) # need portfolio available to be up-to-date with balancing orders try: kept_quote_amount = portfolio.portfolio[common_quote].available / decimal.Decimal(2) return ( (portfolio.portfolio[common_quote].available - kept_quote_amount) / decimal.Decimal(trading_pairs_count) ) except KeyError: # no common_quote in portfolio return trading_constants.ZERO except (decimal.DivisionByZero, decimal.InvalidOperation): # no pair_bases return trading_constants.ZERO class StaggeredOrdersTradingModeConsumer(trading_modes.AbstractTradingModeConsumer): ORDER_DATA_KEY = "order_data" CURRENT_PRICE_KEY = "current_price" SYMBOL_MARKET_KEY = "symbol_market" COMPLETING_TRAILING_KEY = "completing_trailing" def __init__(self, trading_mode): super().__init__(trading_mode) self.skip_orders_creation = False async def cancel_orders_creation(self): self.logger.info(f"Cancelling all orders creation for {self.trading_mode.symbol}") self.skip_orders_creation = True try: while not self.queue.empty(): await asyncio.sleep(0.1) finally: self.logger.info(f"Orders creation fully cancelled for {self.trading_mode.symbol}") self.skip_orders_creation = False async def create_new_orders(self, symbol, final_note, state, **kwargs): # use dict default getter: can't afford missing data data = kwargs[self.CREATE_ORDER_DATA_PARAM] dependencies = kwargs[self.CREATE_ORDER_DEPENDENCIES_PARAM] try: if not self.skip_orders_creation: order_data = data[self.ORDER_DATA_KEY] current_price = data[self.CURRENT_PRICE_KEY] symbol_market = data[self.SYMBOL_MARKET_KEY] return await self.create_order( order_data, current_price, symbol_market, dependencies ) else: self.logger.info(f"Skipped {data.get(self.ORDER_DATA_KEY, '')}") finally: if data[self.COMPLETING_TRAILING_KEY]: for producer in self.trading_mode.producers: # trailing process complete self.logger.info(f"Completed {symbol} trailing process.") producer.is_currently_trailing = False async def create_order( self, order_data, current_price, symbol_market, dependencies: typing.Optional[commons_signals.SignalDependencies] ): created_order = None currency, market = symbol_util.parse_symbol(order_data.symbol).base_and_quote() try: base_available = trading_api.get_portfolio_currency(self.exchange_manager, currency).available quote_available = trading_api.get_portfolio_currency(self.exchange_manager, market).available selling = order_data.side == trading_enums.TradeOrderSide.SELL quantity = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, order_data.symbol, trading_enums.TraderOrderType.SELL_LIMIT if selling else trading_enums.TraderOrderType.BUY_LIMIT, order_data.quantity, order_data.price, order_data.side ) if selling and base_available < quantity and base_available > quantity * CREATED_ORDER_AVAILABLE_FUNDS_ALLOWED_RATIO: quantity = quantity * CREATED_ORDER_AVAILABLE_FUNDS_ALLOWED_RATIO self.logger.info(f"Slightly adapted {order_data.symbol} {order_data.side.value} quantity to {quantity} to fit available funds") elif not selling: cost = quantity * order_data.price if quote_available < cost and quote_available > cost * CREATED_ORDER_AVAILABLE_FUNDS_ALLOWED_RATIO: quantity = quantity * CREATED_ORDER_AVAILABLE_FUNDS_ALLOWED_RATIO self.logger.info(f"Slightly adapted {order_data.symbol} {order_data.side.value} quantity to {quantity} to fit available funds") for order_quantity, order_price in trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( quantity, order_data.price, symbol_market): if selling: if base_available < order_quantity: self.logger.warning( f"Skipping {order_data.symbol} {order_data.side.value} " f"[{self.exchange_manager.exchange_name}] order creation of " f"{order_quantity} at {float(order_price)}: " f"not enough {currency}: available: {base_available}, required: {order_quantity}" ) return [] elif quote_available < order_quantity * order_price: self.logger.warning( f"Skipping {order_data.symbol} {order_data.side.value} " f"[{self.exchange_manager.exchange_name}] order creation of " f"{order_quantity} at {float(order_price)}: " f"not enough {market}: available: {quote_available}, required: {order_quantity * order_price}" ) return [] order_type = trading_enums.TraderOrderType.SELL_LIMIT if selling \ else trading_enums.TraderOrderType.BUY_LIMIT current_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=order_data.symbol, current_price=current_price, quantity=order_quantity, price=order_price, associated_entry_id=order_data.associated_entry_id ) # disable instant fill to avoid looping order fill in simulator current_order.allow_instant_fill = False created_order = await self.trading_mode.create_order( current_order, dependencies=dependencies ) if not created_order: self.logger.warning( f"No order created for {order_data} (cost: {quantity * order_data.price}): " f"incompatible with exchange minimum rules. " f"Limits: {symbol_market[trading_enums.ExchangeConstantsMarketStatusColumns.LIMITS.value]}" ) except trading_errors.MissingFunds as e: raise e except Exception as e: self.logger.exception(e, True, f"Failed to create order : {e}. Order: {order_data}") return None return [] if created_order is None else [created_order] class StaggeredOrdersTradingModeProducer(trading_modes.AbstractTradingModeProducer): FILL = 1 ERROR = 2 NEW = 3 min_quantity = "min_quantity" max_quantity = "max_quantity" min_cost = "min_cost" max_cost = "max_cost" min_price = "min_price" max_price = "max_price" PRICE_FETCHING_TIMEOUT = 60 MISSING_MIRROR_ORDERS_MARKET_REBALANCE_TIMEOUT = 60 # health check once every 3 days HEALTH_CHECK_INTERVAL_SECS = commons_constants.DAYS_TO_SECONDS * 3 # recent filled allowed time delay to consider as pending order_filled callback RECENT_TRADES_ALLOWED_TIME = 10 # when True, orders creation/health check will be performed on start() SCHEDULE_ORDERS_CREATION_ON_START = True ORDERS_DESC = "staggered" # keep track of available funds in order placement process to avoid spending multiple times # the same funds due to async between producers and consumers and the possibility to trade multiple pairs with # shared quote or base AVAILABLE_FUNDS = {} FUNDS_INCREASE_RATIO_THRESHOLD = decimal.Decimal("0.5") # ratio bellow with funds will be reallocated: # used to track new funds and update orders accordingly ALLOWED_MISSED_MIRRORED_ORDERS_ADAPT_DELTA_RATIO = decimal.Decimal("0.5") def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) # no state for this evaluator: always neutral self.state = trading_enums.EvaluatorStates.NEUTRAL self.symbol = trading_mode.symbol self.symbol_market = None self.min_max_order_details = {} fees = trading_api.get_fees(exchange_manager, self.symbol) try: self.max_fees = decimal.Decimal(str(max(fees[trading_enums.ExchangeConstantsMarketPropertyColumns.TAKER.value], fees[trading_enums.ExchangeConstantsMarketPropertyColumns.MAKER.value] ))) except TypeError as err: # don't crash if fees are not available market_status = self.exchange_manager.exchange.get_market_status(self.symbol, with_fixer=False) self.logger.error(f"Error reading fees for {self.symbol}: {err}. Market status: {market_status}") self.max_fees = decimal.Decimal(str(trading_constants.CONFIG_DEFAULT_FEES)) self.flat_increment = None self.flat_spread = None self.current_price = None self.scheduled_health_check = None self.sell_volume_per_order = self.buy_volume_per_order = self.starting_price = trading_constants.ZERO self.mirror_orders_tasks = [] self.mirroring_pause_task = None self.allow_order_funds_redispatch = False self.enable_trailing_up = False self.enable_trailing_down = False self.use_order_by_order_trailing = True # enabled by default self.funds_redispatch_interval = 24 self._expect_missing_orders = False self._skip_order_restore_on_recently_closed_orders = True self._use_recent_trades_for_order_restore = False self._already_created_init_orders = False self.compensate_for_missed_mirror_order = False self.healthy = False # used not to refresh orders when order_fill_callback is processing self.lock = asyncio.Lock() # staggered orders strategy parameters self.symbol_trading_config = None self.use_existing_orders_only = self.limit_orders_count_if_necessary = False self.ignore_exchange_fees = True self.enable_upwards_price_follow = True self.mode = self.spread \ = self.increment = self.operational_depth \ = self.lowest_buy = self.highest_sell \ = None self.single_pair_setup = len(self.trading_mode.trading_config[self.trading_mode.CONFIG_PAIR_SETTINGS]) <= 1 self.mirror_order_delay = self.buy_funds = self.sell_funds = 0 self.allowed_mirror_orders = asyncio.Event() self.allow_virtual_orders = True self.health_check_interval_secs = self.__class__.HEALTH_CHECK_INTERVAL_SECS self.healthy = False self.is_currently_trailing = False self.last_trailing_process_started_at = 0 try: self._load_symbol_trading_config() except KeyError as e: error_message = f"Impossible to start {self.ORDERS_DESC} orders for {self.symbol}: missing " \ f"configuration in trading mode config file. " self.logger.exception(e, True, error_message) return if self.symbol_trading_config is None: configured_pairs = \ [c[self.trading_mode.CONFIG_PAIR] for c in self.trading_mode.trading_config[self.trading_mode.CONFIG_PAIR_SETTINGS]] self.logger.error(f"No {self.ORDERS_DESC} orders configuration for trading pair: {self.symbol}. Add " f"this pair's details into your {self.ORDERS_DESC} orders configuration or disable this " f"trading pairs. Configured {self.ORDERS_DESC} orders pairs are" f" {', '.join(configured_pairs)}") return self.already_errored_on_out_of_window_price = False self.allowed_mirror_orders.set() self.read_config() self._check_params() self._already_created_init_orders = True if self.use_existing_orders_only else False self.logger.debug(f"Loaded healthy config for {self.symbol}") self.healthy = True def _load_symbol_trading_config(self) -> bool: config = self.get_symbol_trading_config(self.symbol) if config is None: return False self.symbol_trading_config = config return True def get_symbol_trading_config(self, symbol): for config in self.trading_mode.trading_config[self.trading_mode.CONFIG_PAIR_SETTINGS]: if config[self.trading_mode.CONFIG_PAIR] == symbol: return config return None def read_config(self): mode = "" try: mode = self.symbol_trading_config[self.trading_mode.CONFIG_MODE] self.mode = StrategyModes(mode) except ValueError as e: self.logger.error(f"Invalid {self.ORDERS_DESC} orders strategy mode: {mode} for {self.symbol}" f"supported modes are {[m.value for m in StrategyModes]}") raise e self.spread = decimal.Decimal(str(self.symbol_trading_config[self.trading_mode.CONFIG_SPREAD] / 100)) self.increment = decimal.Decimal(str(self.symbol_trading_config[self.trading_mode.CONFIG_INCREMENT_PERCENT] / 100)) self.operational_depth = self.symbol_trading_config[self.trading_mode.CONFIG_OPERATIONAL_DEPTH] self.lowest_buy = decimal.Decimal(str(self.symbol_trading_config[self.trading_mode.CONFIG_LOWER_BOUND])) self.highest_sell = decimal.Decimal(str(self.symbol_trading_config[self.trading_mode.CONFIG_UPPER_BOUND])) self.use_existing_orders_only = self.symbol_trading_config.get( self.trading_mode.CONFIG_USE_EXISTING_ORDERS_ONLY, self.use_existing_orders_only) self.mirror_order_delay = self.symbol_trading_config.get(self.trading_mode.CONFIG_MIRROR_ORDER_DELAY, self.mirror_order_delay) self.buy_funds = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_BUY_FUNDS, self.buy_funds))) self.sell_funds = decimal.Decimal(str(self.symbol_trading_config.get(self.trading_mode.CONFIG_SELL_FUNDS, self.sell_funds))) self.ignore_exchange_fees = self.symbol_trading_config.get(self.trading_mode.CONFIG_IGNORE_EXCHANGE_FEES, self.ignore_exchange_fees) self.enable_upwards_price_follow = self.symbol_trading_config.get( self.trading_mode.ENABLE_UPWARDS_PRICE_FOLLOW, self.enable_upwards_price_follow ) async def start(self) -> None: await super().start() if StaggeredOrdersTradingModeProducer.SCHEDULE_ORDERS_CREATION_ON_START and self.healthy: self.logger.debug(f"Initializing orders creation") await self._ensure_staggered_orders_and_reschedule() def get_extra_init_symbol_topics(self) -> typing.Optional[list]: if self.exchange_manager.is_backtesting: # disabled in backtesting as price might not be initialized at this point return None # required as trigger happens independently of price events for initial orders return [commons_enums.InitializationEventExchangeTopics.PRICE.value] async def stop(self): if self.trading_mode is not None: self.trading_mode.flush_trading_mode_consumers() if self.scheduled_health_check is not None: self.scheduled_health_check.cancel() if self.mirroring_pause_task is not None and not self.mirroring_pause_task.done(): self.mirroring_pause_task.cancel() for task in self.mirror_orders_tasks: task.cancel() if self.exchange_manager: if self.exchange_manager.id in StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS: # remove self.exchange_manager.id from available funds StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(self.exchange_manager.id, None) await super().stop() async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): # nothing to do: this is not a strategy related trading mode pass async def is_price_beyond_boundaries(self): open_orders = self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(self.symbol) price = await trading_personal_data.get_up_to_date_price( self.exchange_manager, self.symbol, timeout=self.PRICE_FETCHING_TIMEOUT ) max_order_price = max( order.origin_price for order in open_orders ) # price is above max order price if max_order_price < price and self.enable_upwards_price_follow: return True def _schedule_order_refresh(self): # schedule order creation / health check asyncio.create_task(self._ensure_staggered_orders_and_reschedule()) async def _ensure_staggered_orders_and_reschedule(self): if self.should_stop: return can_create_orders = ( not trading_api.get_is_backtesting(self.exchange_manager) or trading_api.is_mark_price_initialized(self.exchange_manager, symbol=self.symbol) ) and ( trading_api.get_portfolio(self.exchange_manager) != {} or trading_api.is_trader_simulated(self.exchange_manager) ) if can_create_orders: try: await self._ensure_staggered_orders() except asyncio.TimeoutError: can_create_orders = False if not self.should_stop: if can_create_orders: # a None self.health_check_interval_secs disables health check if self.health_check_interval_secs is not None: self.scheduled_health_check = asyncio.get_event_loop().call_later( self.health_check_interval_secs, self._schedule_order_refresh ) else: self.logger.debug(f"Can't yet create initialize orders for {self.symbol}") self.scheduled_health_check = asyncio.get_event_loop().call_soon( self._schedule_order_refresh ) async def trigger_staggered_orders_creation(self): if self.symbol_trading_config: await self._ensure_staggered_orders(ignore_mirror_orders_only=True) else: self.logger.error(f"No configuration for {self.symbol}") def start_mirroring_pause(self, delay): if self.allowed_mirror_orders.is_set(): self.mirroring_pause_task = asyncio.create_task(self.stop_mirror_orders(delay)) else: self.logger.info(f"Cancelling previous {self.symbol} mirror order delay") self.mirroring_pause_task.cancel() self.mirroring_pause_task = asyncio.create_task(self.stop_mirror_orders(delay)) async def stop_mirror_orders(self, delay): self.logger.info(f"Pausing {self.symbol} mirror orders creation for the next {delay} seconds") self.allowed_mirror_orders.clear() await asyncio.sleep(delay) self.allowed_mirror_orders.set() self.logger.info(f"Resuming {self.symbol} mirror orders creation after a {delay} seconds pause") async def _ensure_staggered_orders( self, ignore_mirror_orders_only=False, ignore_available_funds=False, trigger_trailing=False ): _, _, _, self.current_price, self.symbol_market = await trading_personal_data.get_pre_order_data( self.exchange_manager, symbol=self.symbol, timeout=self.PRICE_FETCHING_TIMEOUT ) self.logger.debug(f"{self.symbol} symbol_market initialized") await self.create_state( self._get_new_state_price(), ignore_mirror_orders_only, ignore_available_funds, trigger_trailing ) def _get_new_state_price(self): return decimal.Decimal(str(self.current_price if self.starting_price == 0 else self.starting_price)) async def create_state(self, current_price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing): if current_price is not None: self._refresh_symbol_data(self.symbol_market) async with self.get_lock(), self.trading_mode_trigger(skip_health_check=True): if self.exchange_manager.trader.is_enabled: await self._handle_staggered_orders( current_price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing ) self.logger.debug(f"{self.symbol} orders updated on {self.exchange_name}") async def order_filled_callback(self, filled_order: dict): # create order on the order side new_order = self._create_mirror_order(filled_order) self.logger.debug(f"Creating mirror order: {new_order} after filled order: {filled_order}") filled_price = decimal.Decimal(str( filled_order[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] )) if self.mirror_order_delay == 0 or trading_api.get_is_backtesting(self.exchange_manager): await self._ensure_trailing_and_create_order_when_possible(new_order, filled_price) else: # create order after waiting time self.mirror_orders_tasks.append(asyncio.get_event_loop().call_later( self.mirror_order_delay, asyncio.create_task, self._ensure_trailing_and_create_order_when_possible(new_order, filled_price) )) def _create_mirror_order(self, filled_order: dict): now_selling = filled_order[ trading_enums.ExchangeConstantsOrderColumns.SIDE.value ] == trading_enums.TradeOrderSide.BUY.value new_side = trading_enums.TradeOrderSide.SELL if now_selling else trading_enums.TradeOrderSide.BUY associated_entry_id = filled_order[ trading_enums.ExchangeConstantsOrderColumns.ID.value ] if now_selling else None # don't double count PNL: only record entries on sell orders if self.flat_increment is None: details = "self.flat_increment is unset" if self.symbol_market is None: details = "self.symbol_market is unset. Symbol mark price has not yet been initialized" self.logger.error(f"Impossible to create symmetrical order for {self.symbol}: " f"{details}.") return if self.flat_spread is None: if not self.increment: self.logger.error(f"Impossible to create symmetrical order for {self.symbol}: " f"self.flat_spread is None and self.increment is {self.increment}.") self.flat_spread = trading_personal_data.decimal_adapt_price( self.symbol_market, self.spread * self.flat_increment / self.increment ) mirror_price_difference = self.flat_spread - self.flat_increment # try to get the order origin price to compute mirror order price filled_price = decimal.Decimal(str( filled_order[trading_enums.ExchangeConstantsOrderColumns.PRICE.value] )) maybe_trade, maybe_order = self.exchange_manager.exchange_personal_data.get_trade_or_open_order( filled_order[trading_enums.ExchangeConstantsOrderColumns.ID.value] ) if maybe_trade: # normal case order_origin_price = maybe_trade.origin_price elif maybe_order: # should not happen but still handle it just in case order_origin_price = maybe_order.origin_price else: # can't find order: default to filled price, even though it might be different from origin price self.logger.warning( f"Computing mirror order price using filled order price: no associated trade or order has been " f"found, this can lead to inconsistent order intervals (order: {filled_order})" ) order_origin_price = filled_price price = order_origin_price + mirror_price_difference if now_selling else order_origin_price - mirror_price_difference filled_volume = decimal.Decimal(str(filled_order[trading_enums.ExchangeConstantsOrderColumns.FILLED.value])) fee = filled_order[trading_enums.ExchangeConstantsOrderColumns.FEE.value] volume = self._compute_mirror_order_volume(now_selling, filled_price, price, filled_volume, fee) checked_volume = self._get_available_funds_confirmed_order_volume(now_selling, price, volume) return OrderData(new_side, checked_volume, price, self.symbol, False, associated_entry_id) def _get_available_funds_confirmed_order_volume(self, selling, price, volume): parsed_symbol = symbol_util.parse_symbol(self.symbol) try: if selling: available_funds = trading_api.get_portfolio_currency(self.exchange_manager, parsed_symbol.base).available return min(available_funds, volume) else: available_funds = trading_api.get_portfolio_currency(self.exchange_manager, parsed_symbol.quote).available required_cost = price * volume return min(available_funds, required_cost) / price except decimal.DecimalException as err: self.logger.exception(err, True, f"Error when checking mirror order volume: {err}") return volume def _compute_mirror_order_volume(self, now_selling, filled_price, target_price, filled_volume, paid_fees: dict): # use target volumes if set if self.sell_volume_per_order != trading_constants.ZERO and now_selling: return self.sell_volume_per_order if self.buy_volume_per_order != trading_constants.ZERO and not now_selling: return self.buy_volume_per_order # otherwise: compute mirror volume new_order_quantity = filled_volume if not now_selling: # buying => adapt order quantity new_order_quantity = filled_price / target_price * filled_volume # use max possible volume if self.ignore_exchange_fees: return new_order_quantity # remove exchange fees if paid_fees: base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() fees_in_base = trading_personal_data.get_fees_for_currency(paid_fees, base) fees_in_base += trading_personal_data.get_fees_for_currency(paid_fees, quote) / filled_price if fees_in_base == trading_constants.ZERO: self.logger.debug( f"Zero fees for trade on {self.symbol}" ) else: self.logger.debug( f"No fees given to compute {self.symbol} mirror order size, using default ratio of {self.max_fees}" ) fees_in_base = new_order_quantity * self.max_fees return new_order_quantity - fees_in_base async def _ensure_trailing_and_create_order_when_possible(self, new_order, current_price): if self._should_trigger_trailing(None, None, True): # do not give current price as in this context, having only one-sided orders requires trailing await self._ensure_staggered_orders( trigger_trailing=True, ignore_available_funds=not self._should_lock_available_funds(True) ) else: async with self.get_lock(): await self._lock_portfolio_and_create_order_when_possible(new_order, current_price) async def _lock_portfolio_and_create_order_when_possible(self, new_order, current_price): await asyncio.wait_for(self.allowed_mirror_orders.wait(), timeout=None) async with self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.lock: await self._create_order(new_order, current_price, False, []) def _should_trigger_trailing( self, orders: typing.Optional[list], current_price: typing.Optional[decimal.Decimal], trail_on_missing_orders: bool ) -> bool: if not (self.enable_trailing_up or self.enable_trailing_down): return False existing_orders = ( orders or self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(self.symbol) ) buy_orders = sorted( [order for order in existing_orders if order.side == trading_enums.TradeOrderSide.BUY], key=lambda o: -o.origin_price ) sell_orders = sorted( [order for order in existing_orders if order.side == trading_enums.TradeOrderSide.SELL], key=lambda o: o.origin_price ) # 3 to allow trailing even if a few order from the other side have also been filled one_sided_orders_trailing_threshold = self.operational_depth / 3 if self.enable_trailing_up and not sell_orders: if len(buy_orders) < one_sided_orders_trailing_threshold and not trail_on_missing_orders: (self.logger.info if trail_on_missing_orders else self.logger.warning)( f"{self.symbol} trailing up process aborted: too many missing buy orders. " f"Only {len(buy_orders)} are online while configured total orders is {self.operational_depth}" ) return False # only buy orders remaining: everything has been sold, trigger tailing up when enabled if price is # beyond range if current_price and buy_orders: missing_orders_count = self.operational_depth - len(buy_orders) price_delta = missing_orders_count * self.flat_increment first_order = buy_orders[0] approximated_highest_buy_price = first_order.origin_price + price_delta if current_price >= approximated_highest_buy_price: # current price is beyond grid maximum buy price: trigger trailing return True last_order = buy_orders[-1] if last_order.origin_price - self.flat_increment < trading_constants.ZERO: # not all buy orders could have been created: trigger trailing as there is no way to check # the theoretical max price of the grid return len(buy_orders) >= self.operational_depth / 2 and current_price > first_order.origin_price elif trail_on_missing_orders: # needed for backtesting on-order-fill trailing trigger return True if self.enable_trailing_down and not buy_orders: if len(sell_orders) < one_sided_orders_trailing_threshold and not trail_on_missing_orders: (self.logger.info if trail_on_missing_orders else self.logger.warning)( f"{self.symbol} trailing down process aborted: too many missing sell orders. " f"Only {len(sell_orders)} are online while configured total orders is {self.operational_depth}" ) return False # only sell orders remaining: everything has been bought, trigger tailing up when enabled if price is # beyond range if current_price: missing_orders_count = self.operational_depth - len(sell_orders) price_delta = missing_orders_count * self.flat_increment first_order = sell_orders[0] approximated_lowest_sell_price = first_order.origin_price - price_delta if current_price <= approximated_lowest_sell_price: # current price is beyond grid minimum sell price: trigger trailing return True elif trail_on_missing_orders: # needed for backtesting on-order-fill trailing trigger return True return False def is_in_trailing_process(self) -> bool: if self.is_currently_trailing: last_trailing_duration = ( self.exchange_manager.exchange.get_exchange_current_time() - self.last_trailing_process_started_at ) if last_trailing_duration > MAX_TRAILING_PROCESS_DURATION: self.logger.info(f"Removing trailing process flag: {MAX_TRAILING_PROCESS_DURATION} seconds reached") self.is_currently_trailing = False return self.is_currently_trailing async def _handle_staggered_orders( self, current_price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing ): self._ensure_current_price_in_limit_parameters(current_price) if not ignore_mirror_orders_only and self.use_existing_orders_only: # when using existing orders only, no need to check existing orders (they can't be wrong since they are # already on exchange): only initialize increment and order fill events will do the rest self._set_increment_and_spread(current_price) else: async with self.producer_exchange_wide_lock(self.exchange_manager): if trigger_trailing and self.is_in_trailing_process(): self.logger.debug( f"{self.symbol} on {self.exchange_name}: trailing signal ignored: " f"a trailing process is already running" ) return # use exchange level lock to prevent funds double spend buy_orders, sell_orders, triggering_trailing, create_order_dependencies = await self._generate_staggered_orders( current_price, ignore_available_funds, trigger_trailing ) staggered_orders = self._merged_and_sort_not_virtual_orders(buy_orders, sell_orders) await self._create_not_virtual_orders( staggered_orders, current_price, triggering_trailing, create_order_dependencies ) if staggered_orders: self._already_created_init_orders = True def _should_lock_available_funds(self, trigger_trailing: bool) -> bool: if trigger_trailing: # don't lock available funds during order by order trailing return not self.use_order_by_order_trailing # don't lock available funds again after initial orders creation return not self._already_created_init_orders def _ensure_current_price_in_limit_parameters(self, current_price): message = None if self.highest_sell < current_price: message = f"The current price is hover the {self.ORDERS_DESC} orders boundaries for {self.symbol}: upper " \ f"bound is {self.highest_sell} and price is {current_price}. OctoBot can't trade using " \ f"these settings at this current price. Adjust your {self.ORDERS_DESC} orders upper bound " \ f"settings to use this trading mode." if self.lowest_buy > current_price: message = f"The current price is bellow the {self.ORDERS_DESC} orders boundaries for {self.symbol}: " \ f"lower bound is {self.lowest_buy} and price is {current_price}. OctoBot can't trade using " \ f"these settings at this current price. Adjust your {self.ORDERS_DESC} orders " \ f"lower bound settings to use this trading mode." if message is not None: # Only log once in error, use warning of later messages. self._log_window_error_or_warning(message, not self.already_errored_on_out_of_window_price) self.already_errored_on_out_of_window_price = True else: self.already_errored_on_out_of_window_price = False def _log_window_error_or_warning(self, message, using_error): log_func = self.logger.error if using_error else self.logger.warning log_func(message) async def _generate_staggered_orders( self, current_price, ignore_available_funds, trigger_trailing ): order_manager = self.exchange_manager.exchange_personal_data.orders_manager interfering_orders_pairs = self._get_interfering_orders_pairs(order_manager.get_open_orders()) if interfering_orders_pairs: self.logger.error( f"Impossible to create {self.ORDERS_DESC} orders for {self.symbol} with interfering orders using " f"pair(s): {', '.join(interfering_orders_pairs)}. {self.ORDERS_DESC.capitalize()} orders require no " f"other orders in both base and quote. Please use the Grid Trading Mode with configured Total funds" f" trade with interfering orders." ) return [], [], False, None existing_orders = order_manager.get_open_orders(self.symbol) sorted_orders = sorted(existing_orders, key=lambda order: order.origin_price) recent_trades_time = trading_api.get_exchange_current_time( self.exchange_manager) - self.RECENT_TRADES_ALLOWED_TIME recently_closed_trades = trading_api.get_trade_history(self.exchange_manager, symbol=self.symbol, since=recent_trades_time) recently_closed_trades = sorted(recently_closed_trades, key=lambda trade: trade.origin_price or trade.executed_price) candidate_flat_increment = None trigger_trailing = trigger_trailing or bool( sorted_orders and self._should_trigger_trailing(sorted_orders, current_price, False) ) next_step_dependencies = None trailing_buy_orders = trailing_sell_orders = [] highest_buy = min(current_price, self.highest_sell) lowest_sell = max(current_price, self.lowest_buy) confirmed_trailing = False if trigger_trailing: # trailing has no initial dependencies here _, __, trailing_buy_orders, trailing_sell_orders, next_step_dependencies = await self._prepare_trailing( sorted_orders, recently_closed_trades, self.lowest_buy, highest_buy, lowest_sell, self.highest_sell, current_price, None ) confirmed_trailing = True # trailing will cancel all orders: set state to NEW with no existing order missing_orders, state, sorted_orders = None, self.NEW, [] else: missing_orders, state, candidate_flat_increment = self._analyse_current_orders_situation( sorted_orders, recently_closed_trades, self.lowest_buy, self.highest_sell, current_price ) self._set_increment_and_spread(current_price, candidate_flat_increment) try: if trailing_buy_orders or trailing_sell_orders: buy_orders = trailing_buy_orders sell_orders = trailing_sell_orders else: buy_orders = self._create_orders(self.lowest_buy, highest_buy, trading_enums.TradeOrderSide.BUY, sorted_orders, current_price, missing_orders, state, self.buy_funds, ignore_available_funds, recently_closed_trades) sell_orders = self._create_orders(lowest_sell, self.highest_sell, trading_enums.TradeOrderSide.SELL, sorted_orders, current_price, missing_orders, state, self.sell_funds, ignore_available_funds, recently_closed_trades) if state is self.FILL: self._ensure_used_funds(buy_orders, sell_orders, sorted_orders, recently_closed_trades) elif state is self.NEW: if trigger_trailing and not (buy_orders or sell_orders): self.logger.error(f"Unhandled situation: no orders created for {self.symbol} with trigger_trailing={trigger_trailing}") create_order_dependencies = next_step_dependencies except ForceResetOrdersException: buy_orders, sell_orders, state, create_order_dependencies = await self._reset_orders( sorted_orders, self.lowest_buy, highest_buy, lowest_sell, self.highest_sell, current_price, ignore_available_funds, next_step_dependencies ) if state == self.NEW: self._set_virtual_orders(buy_orders, sell_orders, self.operational_depth) return buy_orders, sell_orders, confirmed_trailing, create_order_dependencies async def _reset_orders( self, sorted_orders, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> tuple[list, list, int, typing.Optional[commons_signals.SignalDependencies]]: self.logger.info("Resetting orders") cancelled_and_dependency_results = await asyncio.gather(*(self._cancel_open_order(order, dependencies) for order in sorted_orders)) orders_dependencies = commons_signals.SignalDependencies() for result in cancelled_and_dependency_results: if result[0] and result[1] is not None: orders_dependencies.extend(result[1]) self._reset_available_funds() state = self.NEW buy_orders = self._create_orders( lowest_buy, highest_buy, trading_enums.TradeOrderSide.BUY, sorted_orders, current_price, [], state, self.buy_funds, ignore_available_funds, [] ) sell_orders = self._create_orders( lowest_sell, highest_sell, trading_enums.TradeOrderSide.SELL, sorted_orders, current_price, [], state, self.sell_funds, ignore_available_funds, [] ) return buy_orders, sell_orders, state, (orders_dependencies or None) def _reset_available_funds(self): base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() self._set_initially_available_funds( base, trading_api.get_portfolio_currency(self.exchange_manager, base).available, ) self._set_initially_available_funds( quote, trading_api.get_portfolio_currency(self.exchange_manager, quote).available, ) def _ensure_used_funds(self, new_buy_orders, new_sell_orders, existing_orders, recently_closed_trades): if not self.allow_order_funds_redispatch: return existing_buy_orders_count = len([ order for order in existing_orders if order.side is trading_enums.TradeOrderSide.BUY ]) existing_sell_orders_count = len(existing_orders) - existing_buy_orders_count updated_orders = sorted( new_buy_orders + new_sell_orders + existing_orders, key=lambda t: self.get_trade_or_order_price(t) ) if (not updated_orders) or (recently_closed_trades and self._skip_order_restore_on_recently_closed_orders): # nothing to check return if (len(updated_orders) >= self.operational_depth and self._get_max_theoretical_orders_count() > self.operational_depth): # has virtual order: not supported return else: # can more or bigger orders be created ? self._ensure_full_funds_usage(updated_orders, existing_buy_orders_count, existing_sell_orders_count) def _get_max_theoretical_orders_count(self): return math.floor( (self.highest_sell - self.lowest_buy - self.flat_spread + self.flat_increment) / self.flat_increment ) if self.flat_increment else 0 def _ensure_full_funds_usage(self, orders, existing_buy_orders_count, existing_sell_orders_count): base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() total_locked_base, total_locked_quote = self._get_locked_funds(orders) max_buy_funds = trading_api.get_portfolio_currency(self.exchange_manager, quote).available + total_locked_quote if self.buy_funds: max_buy_funds = min(max_buy_funds, self.buy_funds) max_sell_funds = trading_api.get_portfolio_currency(self.exchange_manager, base).available + total_locked_base if self.sell_funds: max_sell_funds = min(max_sell_funds, self.sell_funds) used_buy_funds = trading_constants.ZERO used_sell_funds = trading_constants.ZERO total_sell_orders_value = trading_constants.ZERO for order in orders: order_locked_base, order_locked_quote = self._get_order_locked_funds(order) buying = order.side is trading_enums.TradeOrderSide.BUY if ( (used_buy_funds + order_locked_quote <= max_buy_funds) and (buying or used_sell_funds + order_locked_base > max_sell_funds) ): used_buy_funds += order_locked_quote else: used_sell_funds += order_locked_base total_sell_orders_value += order_locked_quote # consider sell orders funds only if they are NOT drastically lower than buy orders funds can_consider_sell_order_funds = total_sell_orders_value > used_buy_funds / decimal.Decimal(2) # consider buy orders funds only if they are NOT drastically lower than sell orders funds can_consider_buy_order_funds = used_buy_funds > total_sell_orders_value / decimal.Decimal(2) if ( # reset if buy or sell funds are underused and sell funds are not overused ( # has buy orders existing_buy_orders_count > 0 and can_consider_buy_order_funds # and buy orders are not using all funds they should and used_buy_funds < max_buy_funds * self.FUNDS_INCREASE_RATIO_THRESHOLD # funds locked in sell orders are lower than the theoretical max funds to sell # (buy orders have not been converted into sell orders) and used_sell_funds < max_sell_funds ) or ( # has sell orders existing_sell_orders_count > 0 and can_consider_sell_order_funds # and sell orders are not using all funds they should and used_sell_funds < max_sell_funds * self.FUNDS_INCREASE_RATIO_THRESHOLD ) ): self.logger.info( f"Triggering order reset: used_buy_funds={used_buy_funds}, max_buy_funds={max_buy_funds} " f"used_sell_funds={used_sell_funds} max_sell_funds={max_sell_funds}" ) # bigger orders can be created raise ForceResetOrdersException else: self.logger.debug(f"No extra funds to dispatch") def get_trade_or_order_price(self, trade_or_order) -> decimal.Decimal: if isinstance(trade_or_order, trading_personal_data.Order): return trade_or_order.origin_price if isinstance(trade_or_order, OrderData): return trade_or_order.price else: return trade_or_order.origin_price or trade_or_order.executed_price def _get_locked_funds(self, orders): locked_base = locked_quote = trading_constants.ZERO for order in orders: order_locked_base, order_locked_quote = self._get_order_locked_funds(order) if order.side is trading_enums.TradeOrderSide.BUY: locked_quote += order_locked_quote else: locked_base += order_locked_base return locked_base, locked_quote def _get_order_locked_funds(self, order): quantity = order.quantity if isinstance(order, OrderData) else order.origin_quantity # don't use remaining quantity price = order.price if isinstance(order, OrderData) else order.origin_price return quantity, quantity * price def _set_increment_and_spread(self, current_price, candidate_flat_increment=None): origin_flat_increment = self.flat_increment if self.flat_increment is None and candidate_flat_increment is not None: self.flat_increment = decimal.Decimal(str(candidate_flat_increment)) elif self.flat_increment is None: self.flat_increment = trading_personal_data.decimal_adapt_price(self.symbol_market, current_price * self.increment) if origin_flat_increment is not self.flat_increment: self.flat_increment = trading_personal_data.decimal_adapt_price(self.symbol_market, self.flat_increment) if self.flat_spread is None and self.flat_increment is not None: self.flat_spread = trading_personal_data.decimal_adapt_price( self.symbol_market, self.spread * self.flat_increment / self.increment ) self.logger.debug(f"{self.symbol} flat spread and increment initialized") def _get_interfering_orders_pairs(self, orders): # Not a problem if allowed funds are set if (self.buy_funds > 0 and self.sell_funds > 0) \ or (self.buy_volume_per_order > 0 and self.sell_volume_per_order > 0): return [] else: current_base, current_quote = symbol_util.parse_symbol(self.symbol).base_and_quote() interfering_pairs = set() for order in orders: order_symbol = order.symbol if order_symbol != self.symbol: base, quote = symbol_util.parse_symbol(order_symbol).base_and_quote() if current_base == base or current_base == quote or current_quote == base or current_quote == quote: interfering_pairs.add(order_symbol) return interfering_pairs def _check_params(self): if self.increment >= self.spread: self.logger.error(f"Your spread_percent parameter should always be higher than your increment_percent" f" parameter: average profit is spread-increment. ({self.symbol})") if self.lowest_buy >= self.highest_sell: self.logger.error(f"Your lower_bound should always be lower than your upper_bound ({self.symbol})") async def _handle_missed_mirror_orders_fills(self, sorted_trades, missing_orders, current_price): if not self.compensate_for_missed_mirror_order or not sorted_trades or not missing_orders: return trades_with_missing_mirror_order_fills = self._find_missing_mirror_order_fills(sorted_trades, missing_orders) if not trades_with_missing_mirror_order_fills: return await self._pack_and_balance_missing_orders(trades_with_missing_mirror_order_fills, current_price) async def _pack_and_balance_missing_orders(self, trades_with_missing_mirror_order_fills, current_price): base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote() self.logger.info( f"Packing {len(trades_with_missing_mirror_order_fills)} missed [{self.exchange_manager.exchange_name}] " f"mirror orders, trades {[trade.to_dict() for trade in trades_with_missing_mirror_order_fills]}" ) to_create_order_quantity = sum( (trade.executed_quantity - trading_personal_data.get_fees_for_currency(trade.fee, base)) * (-1 if trade.side is trading_enums.TradeOrderSide.BUY else 1) for trade in trades_with_missing_mirror_order_fills ) self.logger.info( f"Packed {len(trades_with_missing_mirror_order_fills)} missed [{self.exchange_manager.exchange_name}] " f"balancing quantity into: {to_create_order_quantity} {base}" ) if to_create_order_quantity == trading_constants.ZERO: return # create a market order to balance funds order_type = trading_enums.TraderOrderType.SELL_MARKET if to_create_order_quantity < trading_constants.ZERO \ else trading_enums.TraderOrderType.BUY_MARKET target_amount = abs(to_create_order_quantity) currency_available, _, market_quantity = \ trading_personal_data.get_portfolio_amounts(self.exchange_manager, self.symbol, current_price) limiting_amount = currency_available if order_type is trading_enums.TraderOrderType.SELL_MARKET \ else market_quantity if target_amount > limiting_amount: # use limiting_amount if delta from order_amount is bellow allowed threshold delta = target_amount - limiting_amount try: if delta / target_amount < self.ALLOWED_MISSED_MIRRORED_ORDERS_ADAPT_DELTA_RATIO: target_amount = limiting_amount self.logger.info(f"Adapted balancing quantity according to available amount. Using {target_amount}") except (decimal.DivisionByZero, decimal.InvalidOperation): # leave as is pass buying = order_type is trading_enums.TraderOrderType.BUY_MARKET fees_adapted_target_amount = trading_personal_data.decimal_adapt_order_quantity_because_fees( self.exchange_manager, self.symbol, order_type, target_amount, current_price, trading_enums.TradeOrderSide.BUY if buying else trading_enums.TradeOrderSide.SELL, ) if fees_adapted_target_amount != target_amount: self.logger.info( f"Adapted balancing quantity to comply with exchange fees. Using {fees_adapted_target_amount} " f"instead of {target_amount}" ) target_amount = fees_adapted_target_amount to_create_details = trading_personal_data.decimal_check_and_adapt_order_details_if_necessary( target_amount, current_price, self.symbol_market ) if not to_create_details: self.logger.warning( f"No enough computed funds to recreate packed missed [{self.exchange_manager.exchange_name}] " f"mirror order balancing order on {self.symbol}: target_amount: {target_amount} is not enough " f"for exchange minimal trading amounts rules" ) return for order_amount, order_price in to_create_details: if order_amount > limiting_amount: limiting_currency = base if order_type is trading_enums.TraderOrderType.SELL_MARKET \ else quote other_amount = currency_available if order_type is trading_enums.TraderOrderType.BUY_MARKET \ else market_quantity other_currency = base if order_type is trading_enums.TraderOrderType.BUY_MARKET \ else quote self.logger.warning( f"No enough available funds to create missed [{self.exchange_manager.exchange_name}] mirror " f"order {order_type.value} balancing order on {self.symbol}. " f"Required {float(order_amount)} {limiting_currency}, available {float(limiting_amount)} " f"{limiting_currency} ({other_currency} available: {other_amount})" ) return self.logger.info( f"{len(trades_with_missing_mirror_order_fills)} missed [{self.exchange_manager.exchange_name}] order " f"fills on {self.symbol}, creating a {order_type.value} order of {float(order_amount)} {base} " f"to compensate." ) balancing_order = trading_personal_data.create_order_instance( trader=self.exchange_manager.trader, order_type=order_type, symbol=self.symbol, current_price=order_price, quantity=order_amount, price=order_price, reduce_only=False, ) created_order = await self.trading_mode.create_order(balancing_order) # wait for order to be filled await trading_personal_data.wait_for_order_fill( created_order, self.MISSING_MIRROR_ORDERS_MARKET_REBALANCE_TIMEOUT, True ) def _get_just_filled_unmirrored_missing_order_trade(self, sorted_trades, missing_order_price, missing_order_side): price_increment = self.flat_spread - self.flat_increment price_window = self.flat_increment / decimal.Decimal(4) # each missing order should have is mirror side equivalent in recently_closed_trades # when it is not the case, a fill is missing now_selling = missing_order_side is trading_enums.TradeOrderSide.BUY mirror_order_price = missing_order_price + price_increment if now_selling \ else missing_order_price - price_increment for trade in sorted_trades: # use origin price if available, otherwise use executed price which is less accurate as it # might be different from initial order's origin price lower_window = (trade.origin_price or trade.executed_price) - price_window higher_window = (trade.origin_price or trade.executed_price) + price_window if lower_window < mirror_order_price < higher_window and trade.side is not missing_order_side: # found mirror order fill break if lower_window < missing_order_price < higher_window and trade.side is missing_order_side: # found missing order in trades before mirror order: this missing order has been filled but not yet # replaced by a mirror order return trade return None def _find_missing_mirror_order_fills(self, sorted_trades, missing_orders): trades_with_missing_mirror_order_fills = [] for missing_order_price, missing_order_side in missing_orders: if trade := self._get_just_filled_unmirrored_missing_order_trade( sorted_trades, missing_order_price, missing_order_side ): trades_with_missing_mirror_order_fills.append(trade) if trades_with_missing_mirror_order_fills: def _printable_trade(trade): return f"{trade.side.name} {trade.executed_quantity}@{trade.origin_price or trade.executed_price}" self.logger.info( f"Found {len(trades_with_missing_mirror_order_fills)} {self.symbol} missing order fills based " f"on {len(sorted_trades)} " f"trades. Missing fills: {[_printable_trade(t) for t in trades_with_missing_mirror_order_fills]}, " f"trades: {[_printable_trade(t) for t in trades_with_missing_mirror_order_fills]} " f"[{self.exchange_manager.exchange_name}]" ) return trades_with_missing_mirror_order_fills async def _cancel_open_order( self, order, dependencies: typing.Optional[commons_signals.SignalDependencies] ) -> tuple[bool, typing.Optional[commons_signals.SignalDependencies]]: if not (order.is_cancelled() or order.is_closed()): try: cancelled, cancel_order_dependency = await self.trading_mode.cancel_order(order, dependencies=dependencies) return cancelled, (cancel_order_dependency if cancelled else None) except trading_errors.UnexpectedExchangeSideOrderStateError as err: self.logger.warning(f"Skipped order cancel: {err}, order: {order}") return False, None async def _prepare_trailing( self, sorted_orders: list, recently_closed_trades: list, lowest_buy: decimal.Decimal, highest_buy: decimal.Decimal, lowest_sell: decimal.Decimal, highest_sell: decimal.Decimal, current_price: decimal.Decimal, dependencies: typing.Optional[commons_signals.SignalDependencies], ) -> tuple[list, list, list, list, typing.Optional[commons_signals.SignalDependencies]]: is_trailing_up = len([o for o in sorted_orders if o.side == trading_enums.TradeOrderSide.BUY]) > len(sorted_orders) / 2 log_header = ( f"[{self.exchange_manager.exchange_name}] {self.symbol} @ {current_price} " f"{'order by order' if self.use_order_by_order_trailing else 'full grid'} " f"trailing {'up' if is_trailing_up else 'down'} process: " ) if current_price <= trading_constants.ZERO: self.logger.error( f"Aborting {log_header}current price is {current_price}") return [], [], [], [], None if self.use_order_by_order_trailing: cancelled_orders, orders, trailing_buy_orders, trailing_sell_orders, dependencies = await self._prepare_order_by_order_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, is_trailing_up, dependencies, log_header ) self.is_currently_trailing = True self.last_trailing_process_started_at = self.exchange_manager.exchange.get_exchange_current_time() return cancelled_orders, orders, trailing_buy_orders, trailing_sell_orders, dependencies return await self._prepare_full_grid_trailing( sorted_orders, current_price, dependencies, log_header ) async def _prepare_order_by_order_trailing( self, sorted_orders: list, recently_closed_trades: list, lowest_buy: decimal.Decimal, highest_buy: decimal.Decimal, lowest_sell: decimal.Decimal, highest_sell: decimal.Decimal, current_price: decimal.Decimal, is_trailing_up: bool, dependencies: typing.Optional[commons_signals.SignalDependencies], log_header: str ) -> tuple[list, list, list, list, typing.Optional[commons_signals.SignalDependencies]]: # 1. identify orders to cancel # 1.a find and replace missing orders if any replaced_buy_orders = replaced_sell_orders = [] try: ignore_available_funds = True # trailing happens after initial funds locking, ignore & don't change initial funds replaced_buy_orders, replaced_sell_orders = await self._compute_trailing_replaced_orders( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, log_header ) # 1.b identify orders to cancel: # cancelled = enough orders to create up to the 1st order on the other side using grid settings to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price = self._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_buy_orders+replaced_sell_orders, current_price ) self.logger.info( f"{log_header} Replacing orders at prices: {[float(self.get_trade_or_order_price(o[0])) for o in (to_cancel_orders_with_trailed_prices + [to_execute_order_with_trailing_price])]} with " f"{[float(o[1]) for o in (to_cancel_orders_with_trailed_prices + [to_execute_order_with_trailing_price])]}" ) except TrailingAborted as err: # A normal missing order replacement should happen # Can happen when all orders from a side are missing and price when back to a valid in-grid value self.logger.info(f"{log_header}trailing aborted: {err}. Replacing orders with: {replaced_buy_orders=} {replaced_sell_orders=}") return [], [], replaced_buy_orders, replaced_sell_orders, None except NoOrdersToTrail as err: # happens when all orders are filled at once: use the "new" state to recreate the grid, should be rare self.logger.warning( f"{log_header}no order to trail from, using full grid trailing to balance funds before recreating the grid: {err}" ) return await self._prepare_full_grid_trailing(sorted_orders, current_price, dependencies, log_header) except ValueError as err: self.logger.error(f"{log_header}error when identifying orders to cancel: {err}") return [], [], [], [], None # 2. cancel orders to be replaced with updated prices cancelled_replaced_orders, cancelled_orders, convert_dependencies = await self._cancel_replaced_orders( [order for order, _ in to_cancel_orders_with_trailed_prices + [to_execute_order_with_trailing_price]], dependencies ) # 3. execute extrema order amount as market order to convert funds to_convert_order = to_execute_order_with_trailing_price[0] orders = await self._convert_order_funds( to_convert_order, current_price, convert_dependencies, log_header ) orders_dependencies = signals.get_orders_dependencies(orders) # 4. compute trailing orders trailing_buy_orders, trailing_sell_orders = self._get_updated_trailing_orders( replaced_buy_orders, replaced_sell_orders, cancelled_replaced_orders, to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price, current_price, is_trailing_up ) self.logger.info(f"{log_header}creating {len(trailing_buy_orders)} buy orders and {len(trailing_sell_orders)} sell orders: {trailing_buy_orders=} {trailing_sell_orders=}") return cancelled_orders, orders, trailing_buy_orders, trailing_sell_orders, (orders_dependencies or convert_dependencies or None) async def _cancel_replaced_orders( self, replaced_orders: list[typing.Union[OrderData, trading_personal_data.Order]], dependencies ) -> tuple[list[OrderData], list[trading_personal_data.Order], commons_signals.SignalDependencies]: cancelled_orders = [] cancelled_replaced_orders = [] new_dependencies = commons_signals.SignalDependencies() for order in replaced_orders: if isinstance(order, OrderData): cancelled_replaced_orders.append(order) else: cancelled, cancel_order_dependency = await self._cancel_open_order(order, dependencies) if cancelled: cancelled_orders.append(order) if cancel_order_dependency: new_dependencies.extend(cancel_order_dependency) return cancelled_replaced_orders, cancelled_orders, new_dependencies async def _compute_trailing_replaced_orders( self, sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, log_header ) -> tuple[list[OrderData], list[OrderData]]: missing_orders, state, _ = self._analyse_current_orders_situation( sorted_orders, recently_closed_trades, lowest_buy, highest_sell, current_price ) if state == self.NEW and not sorted_orders: raise NoOrdersToTrail(f"no open order to trail, nothing to replace") if state != self.FILL: raise ValueError(f"unhandled state: {state} (expected: self.FILL: {self.FILL})") replaced_buy_orders = replaced_sell_orders = [] if missing_orders: self.logger.info( f"{log_header}found {len(missing_orders)} missing orders: preparing orders before " f"order by order trailing process {missing_orders}" ) await self._handle_missed_mirror_orders_fills(recently_closed_trades, missing_orders, current_price) replaced_buy_orders = self._create_orders( lowest_buy, highest_buy, trading_enums.TradeOrderSide.BUY, sorted_orders, current_price, missing_orders, state, self.buy_funds, ignore_available_funds, recently_closed_trades ) replaced_sell_orders = self._create_orders( lowest_sell, highest_sell, trading_enums.TradeOrderSide.SELL, sorted_orders, current_price, missing_orders, state, self.sell_funds, ignore_available_funds, recently_closed_trades ) return replaced_buy_orders, replaced_sell_orders async def _convert_order_funds( self, to_convert_order, current_price, convert_dependencies, log_header ) -> list[trading_personal_data.Order]: base, quote = symbol_util.parse_symbol(to_convert_order.symbol).base_and_quote() base_amount_to_convert = to_convert_order.quantity if isinstance(to_convert_order, OrderData) \ else to_convert_order.get_remaining_quantity() if to_convert_order.side is trading_enums.TradeOrderSide.BUY: # replace buy order by a sell order => convert quote to base to_sell = quote to_buy = base amount_to_convert = base_amount_to_convert * self.get_trade_or_order_price(to_convert_order) else: # replace sell order by a buy order => convert base to quote to_sell = base to_buy = quote amount_to_convert = base_amount_to_convert self.logger.info(f"{log_header}selling {amount_to_convert} {base} worth of {to_sell} to buy {to_buy}") # need portfolio available to be up-to-date with cancelled orders orders = await trading_modes.convert_asset_to_target_asset( self.trading_mode, to_sell, to_buy, { self.symbol: { trading_enums.ExchangeConstantsTickersColumns.CLOSE.value: current_price, } }, asset_amount=amount_to_convert, dependencies=convert_dependencies ) orders = [order for order in orders if order is not None] if orders: await asyncio.gather(*[ trading_personal_data.wait_for_order_fill( order, self.MISSING_MIRROR_ORDERS_MARKET_REBALANCE_TIMEOUT, True ) for order in orders ]) return orders def _get_updated_trailing_orders( self, replaced_buy_orders, replaced_sell_orders, cancelled_replaced_orders, to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price, current_price, is_trailing_up ) -> tuple[list[OrderData], list[OrderData]]: to_convert_order = to_execute_order_with_trailing_price[0] trailing_buy_orders = [ buy_order for buy_order in replaced_buy_orders if buy_order not in cancelled_replaced_orders ] trailing_sell_orders = [ sell_order for sell_order in replaced_sell_orders if sell_order not in cancelled_replaced_orders ] # add orders with price covering up to the current price for cancelled_order, trailed_price in (to_cancel_orders_with_trailed_prices + [to_execute_order_with_trailing_price]): trailed_order_side = ( trading_enums.TradeOrderSide.BUY if trailed_price <= current_price else trading_enums.TradeOrderSide.SELL ) if cancelled_order is to_convert_order: # force the order side to be the opposite of the trailing direction and make sure this order # gets created at the side, even if it's at the current price trailed_order_side = trading_enums.TradeOrderSide.SELL if is_trailing_up else trading_enums.TradeOrderSide.BUY ideal_base_quantity = to_convert_order.total_cost / trailed_price parsed_symbol = symbol_util.parse_symbol(to_convert_order.symbol) other_side_currency = parsed_symbol.quote if trailed_order_side is trading_enums.TradeOrderSide.BUY else parsed_symbol.base available_amount = trading_api.get_portfolio_currency(self.exchange_manager, other_side_currency).available available_amount_in_base = available_amount if other_side_currency == parsed_symbol.base else available_amount / trailed_price if available_amount_in_base < ideal_base_quantity: trailing_order_quantity = available_amount_in_base self.logger.warning( f"Not enough available funds to create a full {ideal_base_quantity} {parsed_symbol.base} {to_convert_order.symbol} {trailed_order_side.name} trailing " f"order: available: {available_amount} {other_side_currency} < {ideal_base_quantity} " f"(={available_amount_in_base} {parsed_symbol.base}). Using {trailing_order_quantity} instead." ) else: trailing_order_quantity = ideal_base_quantity else: initial_quantity = cancelled_order.quantity if isinstance(cancelled_order, OrderData) \ else cancelled_order.get_remaining_quantity() if cancelled_order.side is trading_enums.TradeOrderSide.BUY: # trailed buy orders inherit the total cost of the orders they are replacing initial_price = self.get_trade_or_order_price(cancelled_order) trailing_order_quantity = initial_quantity * initial_price / trailed_price else: # trailed sell orders can inherit the quantity of the orders they are replacing trailing_order_quantity = initial_quantity order = OrderData( trailed_order_side, trailing_order_quantity, trailed_price, self.symbol, False ) if trailed_order_side is trading_enums.TradeOrderSide.BUY: trailing_buy_orders.append(order) else: trailing_sell_orders.append(order) return trailing_buy_orders, trailing_sell_orders def _get_orders_to_replace_with_updated_price_for_trailing( self, sorted_orders: list[trading_personal_data.Order], replaced_orders: list[OrderData], current_price: decimal.Decimal ) -> tuple[ list[tuple[typing.Union[trading_personal_data.Order, OrderData], decimal.Decimal]], tuple[trading_personal_data.Order, decimal.Decimal] ]: if not sorted_orders: raise ValueError(f"No input sorted orders, trailing can't happen on {self.symbol}") confirmed_sorted_grid_prices = sorted([ replaced_order.price for replaced_order in replaced_orders ] + [ order.origin_price for order in (sorted_orders) ]) is_trailing_up = current_price > confirmed_sorted_grid_prices[-1] if not is_trailing_up and current_price >= confirmed_sorted_grid_prices[0]: raise TrailingAborted( f"Current price is not beyond grid boundaries: {current_price}, " f"grid min: {confirmed_sorted_grid_prices[0]}, grid max: {confirmed_sorted_grid_prices[-1]}" ) orders_to_replace_with_trailed_price: list[ tuple[typing.Union[trading_personal_data.Order, OrderData], decimal.Decimal] ] = [] if not (self.flat_increment and self.flat_spread): raise ValueError( f"Flat increment and flat spread mush be set {self.flat_increment=} {self.flat_spread=}" ) if is_trailing_up: # trailing up: free enough funds to create orders up to the current price, including 1 sell order above the current price extrema_order_price = confirmed_sorted_grid_prices[-1] if extrema_order_price + self.flat_spread > current_price: # no order to create, only the other side order to handle order_count_to_create = 0 else: order_count_to_create = math.ceil((current_price - self.flat_spread - extrema_order_price) / self.flat_increment) other_side_order_price = extrema_order_price + (self.flat_increment * order_count_to_create) + self.flat_spread else: # trailing down: free enough funds to create orders down to the current price, including 1 buy order below the current price extrema_order_price = confirmed_sorted_grid_prices[0] if extrema_order_price - self.flat_spread < current_price: # no order to create, only the other side order to handle order_count_to_create = 0 else: order_count_to_create = math.ceil((extrema_order_price - self.flat_spread - current_price) / self.flat_increment) other_side_order_price = extrema_order_price - (self.flat_increment * order_count_to_create) - self.flat_spread order_by_price: dict[decimal.Decimal, typing.Union[trading_personal_data.Order, OrderData]] = { self.get_trade_or_order_price(order): order for order in replaced_orders + sorted_orders } # order_to_replace_by_other_side_order should be an open order, not a replaced order order_to_replace_by_other_side_order_price = sorted_orders[0].origin_price if is_trailing_up else sorted_orders[-1].origin_price order_to_replace_by_other_side_order = order_by_price[order_to_replace_by_other_side_order_price] if not isinstance(order_to_replace_by_other_side_order, trading_personal_data.Order): # should never happen raise ValueError(f"Order to replace by other side order is not an open order: {order_to_replace_by_other_side_order}") confirmed_prices = [ price for price in confirmed_sorted_grid_prices if price != order_to_replace_by_other_side_order_price ] # 1 trailing price per confirmed price (don't create more than the number of confirmed prices in case price is way off) trailing_order_prices = [ extrema_order_price + (self.flat_increment * (i + 1) * (1 if is_trailing_up else -1)) for i in range(int(order_count_to_create)) ][-len(confirmed_prices):] remaining_order_prices = collections.deque(sorted( confirmed_prices, key=lambda price: price if is_trailing_up else -price )) self.logger.info(f"trailing_order_prices: {trailing_order_prices} {confirmed_prices=}") for trailing_order_price in trailing_order_prices: # associate each new order price to an existing order order_to_replace_price = remaining_order_prices.popleft() order_to_replace = order_by_price[order_to_replace_price] orders_to_replace_with_trailed_price.append((order_to_replace, trailing_order_price)) return orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) async def _prepare_full_grid_trailing( self, open_orders: list, current_price: decimal.Decimal, dependencies: typing.Optional[commons_signals.SignalDependencies], log_header: str ) -> tuple[list, list, list, list, typing.Optional[commons_signals.SignalDependencies], bool]: # 1. cancel all open orders convert_dependencies = commons_signals.SignalDependencies() try: cancelled_orders = [] self.logger.info(f"{log_header}cancelling {len(open_orders)} open orders on {self.symbol}") for order in open_orders: cancelled, cancel_order_dependency = await self._cancel_open_order(order, dependencies) if cancelled: cancelled_orders.append(order) if cancel_order_dependency: convert_dependencies.extend(cancel_order_dependency) except Exception as err: self.logger.exception(err, True, f"Error in {log_header} cancel orders step: {err}") cancelled_orders = [] # 2. if necessary, convert a part of the funds to be able to create buy and sell orders orders = [] try: parsed_symbol = symbol_util.parse_symbol(self.symbol) available_base_amount = trading_api.get_portfolio_currency(self.exchange_manager, parsed_symbol.base).available available_quote_amount = trading_api.get_portfolio_currency(self.exchange_manager, parsed_symbol.quote).available usable_amount_in_quote = available_quote_amount + (available_base_amount * current_price) config_max_amount = self.buy_funds + (self.sell_funds * current_price) if config_max_amount > trading_constants.ZERO: usable_amount_in_quote = min(usable_amount_in_quote, config_max_amount) # amount = the total amount (in base) to put into the grid at the current price usable_amount_in_base = usable_amount_in_quote / current_price target_base = usable_amount_in_base / decimal.Decimal(2) target_quote = usable_amount_in_quote / decimal.Decimal(2) amount = trading_constants.ZERO to_sell = to_buy = None if available_base_amount < target_base: # buy order to_buy = parsed_symbol.base to_sell = parsed_symbol.quote amount = (target_base - available_base_amount) * current_price if available_quote_amount < target_quote: if amount != trading_constants.ZERO: # can't buy with currencies, this should never happen: log error self.logger.error(f"{log_header}can't buy and sell {parsed_symbol} at the same time.") else: # sell order to_buy = parsed_symbol.quote to_sell = parsed_symbol.base amount = (target_quote - available_quote_amount) / current_price if amount > trading_constants.ZERO: self.logger.info(f"{log_header}selling {amount} {to_sell} to buy {to_buy}") # need portfolio available to be up-to-date with cancelled orders orders = await trading_modes.convert_asset_to_target_asset( self.trading_mode, to_sell, to_buy, { self.symbol: { trading_enums.ExchangeConstantsTickersColumns.CLOSE.value: current_price, } }, asset_amount=amount, dependencies=convert_dependencies ) if orders: await asyncio.gather(*[ trading_personal_data.wait_for_order_fill( order, self.MISSING_MIRROR_ORDERS_MARKET_REBALANCE_TIMEOUT, True ) for order in orders ]) else: self.logger.info(f"{log_header}nothing to buy or sell. Current funds are enough") except Exception as err: self.logger.exception( err, True, f"Error in {log_header}convert into target step: {err}" ) # 3. reset available funds (free funds from cancelled orders) self._reset_available_funds() self.logger.info( f"Completed {log_header} {len(cancelled_orders)} cancelled orders, {len(orders)} " f"created conversion orders" ) orders_dependencies = signals.get_orders_dependencies(orders) self.is_currently_trailing = True self.last_trailing_process_started_at = self.exchange_manager.exchange.get_exchange_current_time() return cancelled_orders, orders, [], [], (orders_dependencies or convert_dependencies or None) def _analyse_current_orders_situation(self, sorted_orders, recently_closed_trades, lower_bound, higher_bound, current_price): if not sorted_orders: return None, self.NEW, None # check if orders are staggered orders return self._bootstrap_parameters(sorted_orders, recently_closed_trades, lower_bound, higher_bound, current_price) def _create_orders(self, lower_bound, upper_bound, side, sorted_orders, current_price, missing_orders, state, allowed_funds, ignore_available_funds, recent_trades) -> list[OrderData]: if lower_bound == upper_bound: self.logger.info( f"No {side.name} orders to create for {self.symbol} lower bound = upper bound = {upper_bound}" ) return [] if lower_bound > upper_bound: self.logger.warning( f"No {side.name} orders to create for {self.symbol}: " f"Your configured increment or spread value is likely too large for the current price. " f"Current price: {current_price}, increment: {self.flat_increment}, spread: {self.flat_spread}. " f"Current price beyond boundaries: " f"computed lower bound: {lower_bound}, computed upper bound: {upper_bound}. " f"Lower bound should be inferior to upper bound." ) return [] selling = side == trading_enums.TradeOrderSide.SELL currency, market = symbol_util.parse_symbol(self.symbol).base_and_quote() order_limiting_currency = currency if selling else market order_limiting_currency_amount = trading_api.get_portfolio_currency(self.exchange_manager, order_limiting_currency).available if state == self.NEW: # create staggered orders return self._create_new_orders_bundle( lower_bound, upper_bound, side, current_price, allowed_funds, ignore_available_funds, selling, order_limiting_currency, order_limiting_currency_amount ) if state == self.FILL: # complete missing orders orders = self._fill_missing_orders( lower_bound, upper_bound, side, sorted_orders, current_price, missing_orders, selling, order_limiting_currency, order_limiting_currency_amount, currency, recent_trades ) return orders if state == self.ERROR: self.logger.error(f"Impossible to create {self.ORDERS_DESC} orders for {self.symbol} when incompatible " f"order are already in place. Cancel these orders of you want to use this trading mode.") return [] def _create_new_orders_bundle( self, lower_bound, upper_bound, side, current_price, allowed_funds, ignore_available_funds, selling, order_limiting_currency, order_limiting_currency_amount ) -> list[OrderData]: orders = [] funds_to_use = self._get_maximum_traded_funds(allowed_funds, order_limiting_currency_amount, order_limiting_currency, selling, ignore_available_funds) if funds_to_use == 0: return [] starting_bound = lower_bound * (1 + self.spread / 2) if selling else upper_bound * (1 - self.spread / 2) self.flat_spread = trading_personal_data.decimal_adapt_price(self.symbol_market, current_price * self.spread) self._create_new_orders(orders, current_price, selling, lower_bound, upper_bound, funds_to_use, order_limiting_currency, starting_bound, side, True, self.mode, order_limiting_currency_amount) return orders def _fill_missing_orders( self, lower_bound, upper_bound, side, sorted_orders, current_price, missing_orders, selling, order_limiting_currency, order_limiting_currency_amount, currency, recent_trades ): orders = [] if missing_orders and [o for o in missing_orders if o[1] is side]: max_quant_per_order = order_limiting_currency_amount / len([o for o in missing_orders if o[1] is side]) missing_orders_around_spread = [] for missing_order_price, missing_order_side in missing_orders: if missing_order_side == side: previous_o = None following_o = None for o in sorted_orders: if previous_o is None: previous_o = o elif o.origin_price > missing_order_price: following_o = o break else: previous_o = o if following_o is None or previous_o.side == following_o.side: decimal_missing_order_price = decimal.Decimal(str(missing_order_price)) # missing order between similar orders quantity = self._get_surrounded_missing_order_quantity( previous_o, following_o, max_quant_per_order, decimal_missing_order_price, recent_trades, current_price, sorted_orders, side ) orders.append(OrderData(missing_order_side, quantity, decimal_missing_order_price, self.symbol, False)) self.logger.debug(f"Creating missing orders not around spread: {orders[-1]} " f"for {self.symbol}") else: missing_orders_around_spread.append((missing_order_price, missing_order_side)) if missing_orders_around_spread: # missing order next to spread starting_bound = upper_bound if selling else lower_bound increment_window = self.flat_increment / 2 order_limiting_currency_available_amount = trading_api.get_portfolio_currency( self.exchange_manager, order_limiting_currency ).available decimal_order_limiting_currency_available_amount = decimal.Decimal( str(order_limiting_currency_available_amount)) portfolio_total = trading_api.get_portfolio_currency(self.exchange_manager, order_limiting_currency).total order_limiting_currency_amount = portfolio_total if order_limiting_currency_available_amount: orders_count, average_order_quantity = \ self._get_order_count_and_average_quantity( current_price, selling, lower_bound, upper_bound, portfolio_total, currency, self.mode ) for missing_order_price, missing_order_side in missing_orders_around_spread: added_missing_order = False limiting_amount_from_this_order = order_limiting_currency_amount price = starting_bound - self.flat_increment if selling else starting_bound + self.flat_increment found_order = False exceeded_price = False i = 0 max_orders_count = max(orders_count, self.operational_depth) while not ( found_order or exceeded_price or limiting_amount_from_this_order < trading_constants.ZERO or i >= max_orders_count ): if price != 0: order_quantity = self._get_spread_missing_order_quantity( average_order_quantity, side, i, orders_count, price, selling, limiting_amount_from_this_order, decimal_order_limiting_currency_available_amount, recent_trades, sorted_orders, current_price ) if price is not None and limiting_amount_from_this_order > 0 and \ price - increment_window <= missing_order_price <= price + increment_window: found_order = True if order_quantity is not None: orders.append(OrderData(side, decimal.Decimal(str(order_quantity)), decimal.Decimal(str(missing_order_price)), self.symbol, False)) added_missing_order = True self.logger.debug(f"Creating missing order around spread {orders[-1]} " f"for {self.symbol}") if order_quantity is not None: used_amount = order_quantity if selling else order_quantity * price limiting_amount_from_this_order -= used_amount price = price - self.flat_increment if selling else price + self.flat_increment if ( selling and price < (missing_order_price - self.flat_increment) ) or ( (not selling) and price > missing_order_price + self.flat_increment ): exceeded_price = True i += 1 if not added_missing_order: self.logger.warning( f"Missing order not restored: price {missing_order_price} side: {missing_order_side}" ) return orders def _get_surrounded_missing_order_quantity( self, previous_order, following_order, max_quant_per_order, order_price, recent_trades, current_price, sorted_orders, side ): selling = side == trading_enums.TradeOrderSide.SELL if sorted_orders: if quantity := self._get_quantity_from_existing_orders( order_price, sorted_orders, selling ): return quantity quantity_from_trades = self._get_quantity_from_recent_trades( order_price, max_quant_per_order, recent_trades, current_price, selling ) return quantity_from_trades or \ decimal.Decimal(str( min( data_util.mean([previous_order.origin_quantity, following_order.origin_quantity]) if following_order else previous_order.origin_quantity, (max_quant_per_order if selling else max_quant_per_order / order_price) ) )) def _get_spread_missing_order_quantity( self, average_order_quantity, side, i, orders_count, price, selling, limiting_amount_from_this_order, order_limiting_currency_available_amount, recent_trades, sorted_orders, current_price ): quantity = None if sorted_orders: quantity = self._get_quantity_from_existing_orders( price, sorted_orders, selling ) if quantity: # quantity is from currently open orders: use it as is return quantity # quantity is not in open orders: infer it if not quantity: quantity = self._get_quantity_from_recent_trades( price, limiting_amount_from_this_order, recent_trades, current_price, selling ) if not quantity: try: quantity = self._get_quantity_from_iteration( average_order_quantity, self.mode, side, i, orders_count, price, price ) except trading_errors.NotSupported: quantity = self._get_quantity_from_existing_boundary_orders( price, sorted_orders, selling ) if quantity: self.logger.info( f"Using boundary orders to compute restored order quantity for {'sell' if selling else 'buy'} " f"order at {price}: no equivalent order for in recent trades (recent trades: " f"{[str(t) for t in recent_trades]})." ) else: self.logger.error( f"Error when computing restored order quantity for {'sell' if selling else 'buy'} order at " f"price: {price}: recent trades or active orders are required." ) return None if quantity is None: return None # always ensure ideal quantity is available limiting_currency_quantity = quantity limiting_cost = limiting_currency_quantity if selling else limiting_currency_quantity * price if limiting_cost > limiting_amount_from_this_order or \ limiting_cost > order_limiting_currency_available_amount: limiting_cost = min( limiting_amount_from_this_order, order_limiting_currency_available_amount ) try: return limiting_cost if selling else limiting_cost / price except decimal.DecimalException as err: self.logger.exception(err, True, f"Error when computing missing order quantity: {err}") return limiting_currency_quantity def _get_quantity_from_existing_orders(self, price, sorted_orders, selling): increment_window = self.flat_increment / 4 price_window_lower_bound = price - increment_window price_window_higher_bound = price + increment_window for order in sorted_orders: if price_window_lower_bound <= order.origin_price <= price_window_higher_bound and ( order.side is (trading_enums.TradeOrderSide.SELL if selling else trading_enums.TradeOrderSide.BUY) ): return order.origin_quantity return None def _get_quantity_from_existing_boundary_orders(self, price, sorted_orders, selling): # Should be the last attempt: compute price from existing orders using cost # of the 1st order on target side and compute linear quantity. Use boundary order as it has the most chances # to remain according to the initial orders costs (compared to an average that could contain results of trades # from the order side, which cost might not be balanced with the current order side) example_order = sorted_orders[-1] if selling else sorted_orders[0] target_side = trading_enums.TradeOrderSide.SELL if selling else trading_enums.TradeOrderSide.BUY if example_order.side is not target_side: # an order from the same side is required return None target_cost = example_order.total_cost # use linear equivalent of the target cost return target_cost / price def _get_quantity_from_recent_trades(self, price, max_quantity, recent_trades, current_price, selling): if not self._use_recent_trades_for_order_restore or not recent_trades: return None # try to find accurate quantity from the available recent trades trade = self._get_associated_trade(price, recent_trades, selling) if trade is None: return None now_selling = trade.side == trading_enums.TradeOrderSide.BUY return self._compute_mirror_order_volume( now_selling, (trade.origin_price or trade.executed_price), price, trade.executed_quantity, trade.fee ) def _get_associated_trade(self, price, trades, selling): increment_window = self.flat_increment / 4 price_window_lower_bound = price - increment_window price_window_higher_bound = price + increment_window for trade in trades: is_sell_trade = trade.side == trading_enums.TradeOrderSide.SELL trade_price = trade.origin_price or trade.executed_price if is_sell_trade == selling: # same side if price_window_lower_bound <= trade_price <= price_window_higher_bound: # found the exact same trade return trade else: # different side: use spread to compute mirror order price price_increment = self.flat_spread - self.flat_increment mirror_order_price = (trade_price - price_increment) \ if is_sell_trade else (trade_price + price_increment) if price_window_lower_bound <= mirror_order_price <= price_window_higher_bound: # found mirror trade return trade return None def _get_maximum_traded_funds(self, allowed_funds, total_available_funds, currency, selling, ignore_available_funds): to_trade_funds = total_available_funds if allowed_funds > 0: if total_available_funds < allowed_funds: self.logger.warning( f"Impossible to create every {self.ORDERS_DESC} orders for {self.symbol} using the total " f"{'sell' if selling else 'buy'} funds configuration ({allowed_funds}): not enough " f"available {currency} funds ({total_available_funds}). Trying to use available funds only.") to_trade_funds = total_available_funds else: to_trade_funds = allowed_funds if not ignore_available_funds and self._is_initially_available_funds_set(currency): # check if enough funds are available unlocked_funds = self._get_available_funds(currency) if to_trade_funds > unlocked_funds: if unlocked_funds <= 0: self.logger.error(f"Impossible to create {self.ORDERS_DESC} orders for {self.symbol}: {currency} " f"funds are already locked for other trading pairs.") return 0 self.logger.warning(f"Impossible to create {self.ORDERS_DESC} orders for {self.symbol} using the " f"total funds ({allowed_funds}): {currency} funds are already locked for other " f"trading pairs. Trying to use remaining funds only.") to_trade_funds = unlocked_funds return to_trade_funds def _create_new_orders(self, orders, current_price, selling, lower_bound, upper_bound, order_limiting_currency_amount, order_limiting_currency, starting_bound, side, virtual_orders, mode, total_available_funds): orders_count, average_order_quantity = \ self._get_order_count_and_average_quantity(current_price, selling, lower_bound, upper_bound, order_limiting_currency_amount, order_limiting_currency, mode) # orders closest to the current price are added first for i in range(orders_count): price = self._get_price_from_iteration(starting_bound, selling, i) if price is not None: quantity = self._get_quantity_from_iteration(average_order_quantity, mode, side, i, orders_count, price, starting_bound) if quantity is not None: orders.append(OrderData(side, quantity, price, self.symbol, virtual_orders)) if not orders: message = "change change the strategy settings to make less but bigger orders." \ if self._use_variable_orders_volume(side) else \ f"reduce {'buy' if side is trading_enums.TradeOrderSide.BUY else 'sell'} the orders volume." # Todo: send it as visible notification to the user instead of warning/error self.logger.warning( f"Not enough {order_limiting_currency} to create {side.name} orders. " f"For the strategy to work better, add {order_limiting_currency} funds or " f"{message}" ) else: # register the locked orders funds if not self._is_initially_available_funds_set(order_limiting_currency): self._set_initially_available_funds(order_limiting_currency, total_available_funds) def _bootstrap_parameters(self, sorted_orders, recently_closed_trades, lower_bound, higher_bound, current_price): # no decimal.Decimal computation here mode = self.mode or None spread = None increment = self.flat_increment or None bigger_buys_closer_to_center = None first_sell = None ratio = None state = self.FILL missing_orders = [] previous_order = None only_sell = False only_buy = False if sorted_orders: if sorted_orders[0].side == trading_enums.TradeOrderSide.SELL: # only sell orders (self.logger.info if self.enable_trailing_down else self.logger.warning)( f"Only sell orders are online for {self.symbol}, " f"{'checking trailing' if self.enable_trailing_down else 'now waiting for the price to go up to create new buy orders'}." ) first_sell = sorted_orders[0] only_sell = True if sorted_orders[-1].side == trading_enums.TradeOrderSide.BUY: # only buy orders (self.logger.info if self.enable_trailing_up else self.logger.warning)( f"Only buy orders are online ({len(sorted_orders)} orders) for {self.symbol}, " f"{'checking trailing' if self.enable_trailing_up else 'now waiting for the price to go down to create new sell orders'}." ) only_buy = True for order in sorted_orders: if order.symbol != self.symbol: self.logger.warning(f"Error when analyzing orders for {self.symbol}: order.symbol != self.symbol.") return None, self.ERROR, None spread_point = False if previous_order is None: previous_order = order else: if previous_order.side != order.side: # changing order side: reached spread point if spread is None: if lower_bound < self.current_price < higher_bound: spread_point = True delta_spread = order.origin_price - previous_order.origin_price if increment is None: self.logger.warning(f"Error when analyzing orders for {self.symbol}: increment " f"is None.") return None, self.ERROR, None else: inferred_spread = self.flat_spread or self.spread * increment / self.increment missing_orders_count = (delta_spread - inferred_spread) / increment # should be 0 when no order is missing if float(missing_orders_count) > 0.5: # missing orders around spread point: symmetrical orders were not created when # orders were filled => re-create them next_missing_order_price = previous_order.origin_price + increment spread_lower_boundary = order.origin_price - inferred_spread # re-create buy orders starting from the closest buy up to spread while next_missing_order_price < self.current_price and \ next_missing_order_price <= spread_lower_boundary: # missing buy order if next_missing_order_price + increment > spread_lower_boundary: # This potential missing buy is the last before spread. Before considering it missing, # make sure that the missing order is not on the selling side of the spread (and # therefore the missing order should be a sell) if recently_closed_trades and self._get_just_filled_unmirrored_missing_order_trade( recently_closed_trades, next_missing_order_price, trading_enums.TradeOrderSide.BUY ): # this order has just been filled on the buying side: the missing order is a sell, # it will be identified as missing right after: exit buy orders loop now break if not self._is_just_closed_order( next_missing_order_price, recently_closed_trades ): missing_orders.append( (next_missing_order_price, trading_enums.TradeOrderSide.BUY)) next_missing_order_price += increment # create sell orders down to the highest buy order + spread # next_missing_order_price - increment is the price of the highest buy order spread_higher_boundary = next_missing_order_price - increment + inferred_spread next_missing_order_price = order.origin_price - increment # re-create sell orders starting from the closest sell down to spread while next_missing_order_price >= spread_higher_boundary: # missing sell order if not self._is_just_closed_order( next_missing_order_price, recently_closed_trades ): missing_orders.append( (next_missing_order_price, trading_enums.TradeOrderSide.SELL)) next_missing_order_price -= increment spread = inferred_spread else: spread = delta_spread # calculations to infer ratio last_buy_cost = previous_order.origin_price * previous_order.origin_quantity first_buy_cost = sorted_orders[0].origin_price * sorted_orders[0].origin_quantity bigger_buys_closer_to_center = last_buy_cost - first_buy_cost > 0 first_sell = order ratio = last_buy_cost / first_buy_cost if bigger_buys_closer_to_center \ else first_buy_cost / last_buy_cost else: self.logger.info(f"Current price ({self.current_price}) for {self.symbol} " f"is out of range.") return None, self.ERROR, None if increment is None: increment = self.flat_increment or order.origin_price - previous_order.origin_price if increment <= 0: self.logger.warning(f"Error when analyzing orders for {self.symbol}: increment <= 0.") return None, self.ERROR, None elif not spread_point: delta_increment = order.origin_price - previous_order.origin_price # skip not-yet-updated orders if previous_order.side == order.side: missing_orders_count = float(delta_increment / increment) if missing_orders_count > 2.5 and not self._expect_missing_orders: self.logger.warning(f"Error when analyzing orders for {self.symbol}: " f"missing_orders_count > 2.5.") if not self._is_just_closed_order(previous_order.origin_price + increment, recently_closed_trades): return None, self.ERROR, None elif missing_orders_count > 1.5: if len(sorted_orders) < self.operational_depth and \ (not self._skip_order_restore_on_recently_closed_orders or ( self._skip_order_restore_on_recently_closed_orders and not recently_closed_trades )): order_price = previous_order.origin_price + increment while order_price < order.origin_price: if not self._is_just_closed_order(order_price, recently_closed_trades): missing_orders.append((order_price, order.side)) order_price += increment previous_order = order if (only_buy or only_sell) and (increment and self.flat_spread): # missing orders between others have been taken into account, now add potential missing orders # on boundaries # make sure that no buy order is missing from previous sell orders (or the opposite) if only_buy: start_price = sorted_orders[-1].origin_price end_price = higher_bound else: start_price = lower_bound end_price = sorted_orders[0].origin_price missing_orders_count = float((end_price - start_price) / increment) if missing_orders_count > 1.5: last_order_price = sorted_orders[-1 if only_buy else 0].origin_price same_side_order_price = last_order_price + increment if only_buy else last_order_price - increment if ( # creating a new buy order <= the current price, its price is previous buy order price + increment same_side_order_price <= current_price and only_buy ) or ( # creating a new sell order >= the current price, its price is previous sell order price - increment same_side_order_price >= current_price and not only_buy ): order_price = same_side_order_price else: # creating a new order on the other side, its price is taking spread into account order_price = last_order_price + self.flat_spread if only_buy else last_order_price - self.flat_spread lowest_sell = lower_bound + self.flat_spread - self.flat_increment highest_buy = higher_bound - self.flat_spread + self.flat_increment to_create_missing_orders_count = self.operational_depth - len(sorted_orders) while lower_bound <= order_price <= higher_bound and ( self.allow_virtual_orders or len(missing_orders) < to_create_missing_orders_count ): if not self._is_just_closed_order(order_price, recently_closed_trades): side = trading_enums.TradeOrderSide.BUY if order_price < current_price \ else trading_enums.TradeOrderSide.SELL min_price = lower_bound if side == trading_enums.TradeOrderSide.BUY else lowest_sell max_price = highest_buy if side == trading_enums.TradeOrderSide.BUY else higher_bound if min_price <= order_price <= max_price: missing_orders.append((order_price, side)) next_price = order_price + increment if only_buy else order_price - increment price_delta = increment if order_price <= current_price <= next_price or order_price >= current_price >= next_price: # about to switch side: apply spread price_delta = self.flat_spread order_price = order_price + price_delta if only_buy else order_price - price_delta if ratio is not None: first_sell_cost = first_sell.origin_price * first_sell.origin_quantity last_sell_cost = sorted_orders[-1].origin_price * sorted_orders[-1].origin_quantity bigger_sells_closer_to_center = first_sell_cost - last_sell_cost > 0 if bigger_buys_closer_to_center is not None and bigger_sells_closer_to_center is not None: if bigger_buys_closer_to_center: if bigger_sells_closer_to_center: mode = StrategyModes.NEUTRAL if 0.1 < ratio - 1 < 0.5 else StrategyModes.MOUNTAIN else: mode = StrategyModes.SELL_SLOPE else: if bigger_sells_closer_to_center: mode = StrategyModes.BUY_SLOPE else: mode = StrategyModes.VALLEY if mode is None or increment is None or spread is None: self.logger.warning(f"Error when analyzing orders for {self.symbol}: mode is None or increment " f"is None or spread is None.") return None, self.ERROR, None if increment is None or (not (only_sell or only_buy) and spread is None): self.logger.warning(f"Error when analyzing orders for {self.symbol}: increment is None or " f"(not(only_sell or only_buy) and spread is None).") return None, self.ERROR, None return missing_orders, state, increment else: # no orders return None, self.ERROR, None def _is_just_closed_order(self, price, recently_closed_trades): if not self._skip_order_restore_on_recently_closed_orders: return False if self.flat_increment is None: return len(recently_closed_trades) else: inc = self.flat_spread * decimal.Decimal("1.5") for trade in recently_closed_trades: trade_price = trade.origin_price or trade.executed_price if trade_price - inc <= price <= trade_price + inc: return True return False @staticmethod def _spread_in_recently_closed_order(min_amount, max_amount, sorted_closed_orders): for order in sorted_closed_orders: if min_amount <= order.get_origin_price() <= max_amount: return True return False @staticmethod def _merged_and_sort_not_virtual_orders(buy_orders, sell_orders): # create sell orders first follows by buy orders return StaggeredOrdersTradingModeProducer._filter_virtual_order(sell_orders) + \ StaggeredOrdersTradingModeProducer._filter_virtual_order(buy_orders) @staticmethod def _filter_virtual_order(orders): return [order for order in orders if not order.is_virtual] @staticmethod def _set_virtual_orders(buy_orders, sell_orders, operational_depth): # all orders that are further than self.operational_depth are virtual orders_count = 0 buy_index = 0 sell_index = 0 at_least_one_added = True while orders_count < operational_depth and at_least_one_added: # priority to orders closer to current price at_least_one_added = False if len(buy_orders) > buy_index: buy_orders[buy_index].is_virtual = False buy_index += 1 orders_count += 1 at_least_one_added = True if len(sell_orders) > sell_index and orders_count < operational_depth: sell_orders[sell_index].is_virtual = False sell_index += 1 orders_count += 1 at_least_one_added = True def _get_order_count_and_average_quantity(self, current_price, selling, lower_bound, upper_bound, holdings, currency, mode): if lower_bound >= upper_bound: self.logger.error(f"Invalid bounds for {self.symbol}: too close to the current price") return 0, 0 if selling: order_distance = upper_bound - (lower_bound + self.flat_spread / 2) else: order_distance = (upper_bound - self.flat_spread / 2) - lower_bound order_count_divisor = self.flat_increment orders_count = math.floor(order_distance / order_count_divisor + 1) if order_count_divisor else 0 if orders_count < 1: self.logger.warning(f"Impossible to create {'sell' if selling else 'buy'} orders for {currency}: " f"not enough funds.") return 0, 0 if self._use_variable_orders_volume(trading_enums.TradeOrderSide.SELL if selling else trading_enums.TradeOrderSide.BUY): return self._ensure_average_order_quantity(orders_count, current_price, selling, holdings, currency, mode) else: return self._get_orders_count_from_fixed_volume(selling, current_price, holdings, orders_count) def _use_variable_orders_volume(self, side): return (self.sell_volume_per_order == decimal.Decimal(0) and side is trading_enums.TradeOrderSide.SELL) \ or self.buy_volume_per_order == decimal.Decimal(0) def _get_orders_count_from_fixed_volume(self, selling, current_price, holdings, orders_count): volume_in_currency = self.sell_volume_per_order if selling else current_price * self.buy_volume_per_order orders_count = min(math.floor(holdings / volume_in_currency), orders_count) if volume_in_currency else 0 return orders_count, self.sell_volume_per_order if selling else self.buy_volume_per_order def _ensure_average_order_quantity(self, orders_count, current_price, selling, holdings, currency, mode): if not (orders_count and current_price): # avoid div by 0 self.logger.warning( f"Can't compute average order quantity: orders_count={orders_count} and current_price={current_price}" ) return 0, 0 holdings_in_quote = holdings if selling else holdings / current_price average_order_quantity = holdings_in_quote / orders_count min_order_quantity, max_order_quantity = self._get_min_max_quantity(average_order_quantity, self.mode) if self.min_max_order_details[self.min_quantity] is not None \ and self.min_max_order_details[self.min_cost] is not None: min_quantity = max(self.min_max_order_details[self.min_quantity], self.min_max_order_details[self.min_cost] / current_price) min_quantity = min_quantity * decimal.Decimal(TEN_PERCENT_DECIMAL) # increase min quantity by 10% to be sure to be # able to create orders in minimal funds conditions adapted_min_order_quantity = trading_personal_data.decimal_adapt_quantity( self.symbol_market, min_order_quantity ) adapted_min_quantity = trading_personal_data.decimal_adapt_quantity(self.symbol_market, min_quantity) if adapted_min_order_quantity < adapted_min_quantity: # 1.01 to account for order creation rounding if holdings_in_quote < average_order_quantity * ONE_PERCENT_DECIMAL: return 0, 0 elif self.limit_orders_count_if_necessary: self.logger.warning(f"Not enough funds to create every {self.symbol} {self.ORDERS_DESC} " f"{trading_enums.TradeOrderSide.SELL.name if selling else trading_enums.TradeOrderSide.BUY.name} " f"orders according to exchange's rules. Creating the maximum possible number " f"of valid orders instead.") return self._adapt_orders_count_and_quantity(holdings_in_quote, adapted_min_quantity, mode) else: min_funds = self._get_min_funds(orders_count, min_quantity, self.mode, current_price) self.logger.error(f"Impossible to create {self.symbol} {self.ORDERS_DESC} " f"{trading_enums.TradeOrderSide.SELL.name if selling else trading_enums.TradeOrderSide.BUY.name} " f"orders: minimum quantity for {self.mode.value} mode is lower than the minimum " f"allowed for this trading pair on this exchange: requested minimum: " f"{min_order_quantity} and exchange minimum is {min_quantity}. " f"Minimum required funds are {min_funds}{f' {currency}' if currency else ''}.") return 0, 0 return orders_count, average_order_quantity def _adapt_orders_count_and_quantity(self, holdings, min_quantity, mode): # called when there are enough funds for at least one order but too many orders are requested min_average_quantity = self._get_average_quantity_from_exchange_minimal_requirements(min_quantity, mode) if 2 * holdings > min_average_quantity >= holdings: return 1, min_average_quantity max_orders_count = math.floor(holdings / min_average_quantity) if min_average_quantity else 0 if max_orders_count > 0: # count remaining holdings if any average_quantity = min_average_quantity + \ (holdings - min_average_quantity * max_orders_count) / max_orders_count return max_orders_count, average_quantity return 0, 0 def _get_price_from_iteration(self, starting_bound, is_selling, iteration): price_step = self.flat_increment * iteration price = starting_bound + price_step if is_selling else starting_bound - price_step if self.min_max_order_details[self.min_price] and price < self.min_max_order_details[self.min_price]: return None return price def _get_quantity_from_iteration(self, average_order_quantity, mode, side, iteration, max_iteration, price, starting_bound): multiplier_price_ratio = 1 min_quantity, max_quantity = self._get_min_max_quantity(average_order_quantity, mode) delta = max_quantity - min_quantity if max_iteration == 1: quantity = average_order_quantity scaled_quantity = quantity else: if iteration >= max_iteration: raise trading_errors.NotSupported iterations_progress = iteration / (max_iteration - 1) if StrategyModeMultipliersDetails[mode][side] == INCREASING: multiplier_price_ratio = 1 - iterations_progress elif StrategyModeMultipliersDetails[mode][side] == DECREASING: multiplier_price_ratio = iterations_progress elif StrategyModeMultipliersDetails[mode][side] == STABLE: multiplier_price_ratio = 0 if price <= 0: return None quantity = (min_quantity + (decimal.Decimal(str(delta)) * decimal.Decimal(str(multiplier_price_ratio)))) # when self.quote_volume_per_order is set, keep the same volume everywhere scaled_quantity = quantity * (starting_bound / price if self._use_variable_orders_volume(side) else trading_constants.ONE) # reduce last order quantity to avoid python float representation issues if iteration == max_iteration - 1 and self._use_variable_orders_volume(side): scaled_quantity = scaled_quantity * decimal.Decimal("0.999") quantity = quantity * decimal.Decimal("0.999") if self._is_valid_order_quantity_for_exchange(scaled_quantity, price): return scaled_quantity if self._is_valid_order_quantity_for_exchange(quantity, price): return quantity return None def _is_valid_order_quantity_for_exchange(self, quantity, price): if self.min_max_order_details[self.min_quantity] and (quantity < self.min_max_order_details[self.min_quantity]): return False cost = quantity * price if self.min_max_order_details[self.min_cost] and (cost < self.min_max_order_details[self.min_cost]): return False return True def _get_min_funds(self, orders_count, min_order_quantity, mode, current_price): mode_multiplier = StrategyModeMultipliersDetails[mode][MULTIPLIER] required_average_quantity = min_order_quantity / (1 - mode_multiplier / 2) if self.min_cost in self.min_max_order_details: average_cost = current_price * required_average_quantity if self.min_max_order_details[self.min_cost]: min_cost = self.min_max_order_details[self.min_cost] if average_cost < min_cost: required_average_quantity = min_cost / current_price return orders_count * required_average_quantity * TEN_PERCENT_DECIMAL @staticmethod def _get_average_quantity_from_exchange_minimal_requirements(exchange_min, mode): mode_multiplier = StrategyModeMultipliersDetails[mode][MULTIPLIER] # add 1% to prevent rounding issues return exchange_min / (1 - mode_multiplier / 2) * ONE_PERCENT_DECIMAL @staticmethod def _get_min_max_quantity(average_order_quantity, mode): mode_multiplier = StrategyModeMultipliersDetails[mode][MULTIPLIER] min_quantity = average_order_quantity * (1 - mode_multiplier / 2) max_quantity = average_order_quantity * (1 + mode_multiplier / 2) return min_quantity, max_quantity async def _create_order(self, order, current_price, completing_trailing, dependencies: list[str]): data = { StaggeredOrdersTradingModeConsumer.ORDER_DATA_KEY: order, StaggeredOrdersTradingModeConsumer.CURRENT_PRICE_KEY: current_price, StaggeredOrdersTradingModeConsumer.SYMBOL_MARKET_KEY: self.symbol_market, StaggeredOrdersTradingModeConsumer.COMPLETING_TRAILING_KEY: completing_trailing, } state = trading_enums.EvaluatorStates.LONG if order.side is trading_enums.TradeOrderSide.BUY else trading_enums.EvaluatorStates.SHORT await self.submit_trading_evaluation(cryptocurrency=self.trading_mode.cryptocurrency, symbol=self.trading_mode.symbol, time_frame=None, state=state, data=data, dependencies=dependencies) async def _create_not_virtual_orders( self, orders_to_create: list, current_price: decimal.Decimal, triggering_trailing: bool, dependencies: typing.Optional[commons_signals.SignalDependencies] ): locks_available_funds = self._should_lock_available_funds(triggering_trailing) for index, order in enumerate(orders_to_create): is_completing_trailing = triggering_trailing and (index == len(orders_to_create) - 1) await self._create_order(order, current_price, is_completing_trailing, dependencies) if locks_available_funds: base, quote = symbol_util.parse_symbol(order.symbol).base_and_quote() # keep track of the required funds volume = order.quantity if order.side is trading_enums.TradeOrderSide.SELL \ else order.price * order.quantity self._remove_from_available_funds( base if order.side is trading_enums.TradeOrderSide.SELL else quote, volume ) def _refresh_symbol_data(self, symbol_market): min_quantity, max_quantity, min_cost, max_cost, min_price, max_price = \ trading_personal_data.get_min_max_amounts(symbol_market) self.min_max_order_details[self.min_quantity] = None if min_quantity is None \ else decimal.Decimal(str(min_quantity)) self.min_max_order_details[self.max_quantity] = None if max_quantity is None \ else decimal.Decimal(str(max_quantity)) self.min_max_order_details[self.min_cost] = None if min_cost is None \ else decimal.Decimal(str(min_cost)) self.min_max_order_details[self.max_cost] = None if max_cost is None \ else decimal.Decimal(str(max_cost)) self.min_max_order_details[self.min_price] = None if min_price is None \ else decimal.Decimal(str(min_price)) self.min_max_order_details[self.max_price] = None if max_price is None \ else decimal.Decimal(str(max_price)) @classmethod def get_should_cancel_loaded_orders(cls): return False def _remove_from_available_funds(self, currency, amount) -> None: if self.exchange_manager.id in StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS: StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[self.exchange_manager.id][currency] = \ StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[self.exchange_manager.id][currency] - amount def _set_initially_available_funds(self, currency, amount) -> None: if self.exchange_manager.id not in StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS: StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[self.exchange_manager.id] = {} StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[self.exchange_manager.id][currency] = amount def _is_initially_available_funds_set(self, currency) -> bool: try: return currency in StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[self.exchange_manager.id] except KeyError: return False def _get_available_funds(self, currency) -> float: try: # only used when creating orders in NEW state, when NOT ignoring available funds return StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[self.exchange_manager.id][currency] except KeyError: return 0 # syntax: "async with xxx.get_lock():" def get_lock(self): return self.lock ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/tests/__init__.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. ================================================ FILE: Trading/Mode/staggered_orders_trading_mode/tests/test_staggered_orders_trading_mode.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import pytest import copy import os.path import asyncio import mock import decimal import contextlib import async_channel.util as channel_util import octobot_tentacles_manager.api as tentacles_manager_api import octobot_backtesting.api as backtesting_api import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.constants as commons_constants import octobot_commons.symbols as symbol_util import octobot_commons.tests.test_config as test_config import octobot_commons.signals as commons_signals import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges import octobot_trading.personal_data as trading_personal_data import octobot_trading.constants as trading_constants import octobot_trading.signals as trading_signals import octobot_trading.modes import tentacles.Trading.Mode.staggered_orders_trading_mode.staggered_orders_trading as staggered_orders_trading import tests.test_utils.config as test_utils_config import tests.test_utils.memory_check_util as memory_check_util import tests.test_utils.test_exchanges as test_exchanges import tests.test_utils.trading_modes as test_trading_modes # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio async def _init_trading_mode(config, exchange_manager, symbol): staggered_orders_trading.StaggeredOrdersTradingModeProducer.SCHEDULE_ORDERS_CREATION_ON_START = False mode = staggered_orders_trading.StaggeredOrdersTradingMode(config, exchange_manager) mode.symbol = None if mode.get_is_symbol_wildcard() else symbol mode.trading_config = _get_multi_symbol_staggered_config() await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) mode.producers[0].PRICE_FETCHING_TIMEOUT = 0.5 mode.producers[0].allow_order_funds_redispatch = True test_trading_modes.set_ready_to_start(mode.producers[0]) return mode, mode.producers[0] @contextlib.asynccontextmanager async def _get_tools(symbol, btc_holdings=None, additional_portfolio={}, fees=None): tentacles_manager_api.reload_tentacle_info() exchange_manager = None try: config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USD"] = 1000 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO][ "BTC"] = 10 if btc_holdings is None else btc_holdings config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO].update(additional_portfolio) if fees is not None: config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_SIMULATOR_FEES][ commons_constants.CONFIG_SIMULATOR_FEES_TAKER] = fees config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_SIMULATOR_FEES][ commons_constants.CONFIG_SIMULATOR_FEES_MAKER] = fees exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.load_test_tentacles_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() # set BTC/USDT price at 1000 USDT if symbol not in exchange_manager.client_symbols: exchange_manager.client_symbols.append(symbol) trading_api.force_set_mark_price(exchange_manager, symbol, 1000) mode, producer = await _init_trading_mode(config, exchange_manager, symbol) producer.lowest_buy = decimal.Decimal(1) producer.highest_sell = decimal.Decimal(10000) producer.operational_depth = 50 producer.spread = decimal.Decimal("0.06") producer.increment = decimal.Decimal("0.04") producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN yield producer, mode.get_trading_mode_consumers()[0], exchange_manager finally: if exchange_manager: await _stop(exchange_manager) @contextlib.asynccontextmanager async def _get_tools_multi_symbol(): exchange_manager = None try: config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USD"] = 1000 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["BTC"] = 10 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["ETH"] = 20 config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["NANO"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data")]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() btc_usd_mode, btcusd_producer = await _init_trading_mode(config, exchange_manager, "BTC/USD") eth_usdt_mode, eth_usdt_producer = await _init_trading_mode(config, exchange_manager, "ETH/USDT") nano_usdt_mode, nano_usdt_producer = await _init_trading_mode(config, exchange_manager, "NANO/USDT") btcusd_producer.lowest_buy = decimal.Decimal(1) btcusd_producer.highest_sell = decimal.Decimal(10000) btcusd_producer.operational_depth = 50 btcusd_producer.spread = decimal.Decimal("0.06") btcusd_producer.increment = decimal.Decimal("0.04") btcusd_producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN eth_usdt_producer.lowest_buy = decimal.Decimal(20) eth_usdt_producer.highest_sell = decimal.Decimal(5000) eth_usdt_producer.operational_depth = 30 eth_usdt_producer.spread = decimal.Decimal("0.07") eth_usdt_producer.increment = decimal.Decimal("0.03") eth_usdt_producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN nano_usdt_producer.lowest_buy = decimal.Decimal(20) nano_usdt_producer.highest_sell = decimal.Decimal(5000) nano_usdt_producer.operational_depth = 30 nano_usdt_producer.spread = decimal.Decimal("0.07") nano_usdt_producer.increment = decimal.Decimal("0.03") nano_usdt_producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN yield btcusd_producer, eth_usdt_producer, nano_usdt_producer, exchange_manager finally: if exchange_manager: await _stop(exchange_manager) async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() async def test_run_independent_backtestings_with_memory_check(): """ Should always be called first here to avoid other tests' related memory check issues """ staggered_orders_trading.StaggeredOrdersTradingModeProducer.SCHEDULE_ORDERS_CREATION_ON_START = True tentacles_setup_config = tentacles_manager_api.create_tentacles_setup_config_with_tentacles( staggered_orders_trading.StaggeredOrdersTradingMode ) await memory_check_util.run_independent_backtestings_with_memory_check(test_config.load_test_config(), tentacles_setup_config) async def test_ensure_staggered_orders(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools assert producer.state == trading_enums.EvaluatorStates.NEUTRAL assert producer.current_price is None # create as task to allow creator's queue to get processed await asyncio.create_task(_check_open_orders_count(exchange_manager, 0)) # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \ as _ensure_current_price_in_limit_parameters_mock: await producer._ensure_staggered_orders() _ensure_current_price_in_limit_parameters_mock.assert_called_once() # price info: create trades assert producer.current_price == 4000 assert producer.state == trading_enums.EvaluatorStates.NEUTRAL await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) async def test_multi_symbol(): async with _get_tools_multi_symbol() as tools: btcusd_producer, eth_usdt_producer, nano_usdt_producer, exchange_manager = tools trading_api.force_set_mark_price(exchange_manager, btcusd_producer.symbol, 100) await btcusd_producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, btcusd_producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len(orders) == btcusd_producer.operational_depth assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 25 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 25 trading_api.force_set_mark_price(exchange_manager, eth_usdt_producer.symbol, 200) await eth_usdt_producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, btcusd_producer.operational_depth + eth_usdt_producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 40 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 40 trading_api.force_set_mark_price(exchange_manager, nano_usdt_producer.symbol, 200) await nano_usdt_producer._ensure_staggered_orders() # no new order await asyncio.create_task(_check_open_orders_count(exchange_manager, btcusd_producer.operational_depth + eth_usdt_producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 40 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 40 assert nano_usdt_producer._get_interfering_orders_pairs(orders) == {"ETH/USDT"} # new ETH USDT evaluation, price changed # -2 order would be filled original_orders = copy.copy(orders) to_fill_order = original_orders[-2] await _fill_order(to_fill_order, exchange_manager, producer=eth_usdt_producer) # filled order and created a new one await asyncio.create_task(_check_open_orders_count(exchange_manager, len(original_orders))) trading_api.force_set_mark_price(exchange_manager, eth_usdt_producer.symbol, 190) await nano_usdt_producer._ensure_staggered_orders() # did nothing await asyncio.create_task(_check_open_orders_count(exchange_manager, len(original_orders))) assert staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS == {} async def test_available_funds_management(): async with _get_tools_multi_symbol() as tools: btcusd_producer, eth_usdt_producer, nano_usdt_producer, exchange_manager = tools assert staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS == {} trading_api.force_set_mark_price(exchange_manager, btcusd_producer.symbol, 100) await btcusd_producer._ensure_staggered_orders() available_funds = \ staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS[exchange_manager.id] assert len(available_funds) == 2 btc_available_funds = available_funds["BTC"] usd_available_funds = available_funds["USD"] assert btc_available_funds < decimal.Decimal("9.9") assert usd_available_funds < decimal.Decimal("31") await asyncio.create_task(_check_open_orders_count(exchange_manager, btcusd_producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) pf_btc_available_funds = trading_api.get_portfolio_currency(exchange_manager, "BTC").available # ensure there at least the same (or more) actual portfolio available funds than on the producer value # (due to exchange rounding reducing some amounts) assert pf_btc_available_funds * decimal.Decimal("0.999") <= btc_available_funds <= pf_btc_available_funds pf_usd_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert pf_usd_available_funds * decimal.Decimal("0.999") <= usd_available_funds <= pf_usd_available_funds assert len(orders) == btcusd_producer.operational_depth assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 25 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 25 trading_api.force_set_mark_price(exchange_manager, eth_usdt_producer.symbol, 200) await eth_usdt_producer._ensure_staggered_orders() assert len(available_funds) == 4 # did not change previous funds assert btc_available_funds == available_funds["BTC"] assert usd_available_funds == available_funds["USD"] eth_available_funds = available_funds["ETH"] usdt_available_funds = available_funds["USDT"] assert eth_available_funds < decimal.Decimal("19.6") assert usdt_available_funds < decimal.Decimal("753") await asyncio.create_task(_check_open_orders_count(exchange_manager, btcusd_producer.operational_depth + eth_usdt_producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) pf_eth_available_funds = trading_api.get_portfolio_currency(exchange_manager, "ETH").available assert pf_eth_available_funds * decimal.Decimal("0.999") <= eth_available_funds <= pf_eth_available_funds pf_usdt_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USDT").available assert pf_usdt_available_funds * decimal.Decimal("0.999") <= usdt_available_funds <= pf_usdt_available_funds assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 40 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 40 trading_api.force_set_mark_price(exchange_manager, nano_usdt_producer.symbol, 200) await nano_usdt_producer._ensure_staggered_orders() # did not change available funds assert len(available_funds) == 4 assert btc_available_funds == available_funds["BTC"] assert usd_available_funds == available_funds["USD"] assert eth_available_funds == available_funds["ETH"] assert usdt_available_funds == available_funds["USDT"] # no new order await asyncio.create_task(_check_open_orders_count(exchange_manager, btcusd_producer.operational_depth + eth_usdt_producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 40 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 40 assert nano_usdt_producer._get_interfering_orders_pairs(orders) == {"ETH/USDT"} # new ETH USDT evaluation, price changed # -2 order would be filled original_orders = copy.copy(orders) to_fill_order = original_orders[-2] await _fill_order(to_fill_order, exchange_manager, producer=eth_usdt_producer) trading_api.force_set_mark_price(exchange_manager, eth_usdt_producer.symbol, 190) await nano_usdt_producer._ensure_staggered_orders() # did nothing # did not change available funds assert len(available_funds) == 4 assert btc_available_funds == available_funds["BTC"] assert usd_available_funds == available_funds["USD"] assert eth_available_funds == available_funds["ETH"] assert usdt_available_funds == available_funds["USDT"] await asyncio.create_task(_check_open_orders_count(exchange_manager, len(original_orders))) # clear available funds assert staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS == {} async def test_ensure_staggered_orders_with_target_sell_and_buy_funds(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.sell_funds = decimal.Decimal("0.001") producer.buy_funds = decimal.Decimal(100) # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() btc_available_funds = producer._get_available_funds("BTC") usd_available_funds = producer._get_available_funds("USD") # btc_available_funds for reduced because orders are not created assert 10 - 0.001 <= btc_available_funds < 10 assert 1000 - 100 <= usd_available_funds < 1000 # price info: create trades assert producer.current_price == 4000 assert producer.state == trading_enums.EvaluatorStates.NEUTRAL await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) pf_btc_available_funds = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pf_usd_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert pf_btc_available_funds >= 9.999 assert pf_usd_available_funds >= 900 assert pf_btc_available_funds >= btc_available_funds assert pf_usd_available_funds >= usd_available_funds async def test_ensure_staggered_orders_with_unavailable_funds(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer._set_initially_available_funds("BTC", decimal.Decimal(1)) producer._set_initially_available_funds("USD", decimal.Decimal(400)) # set BTC/USD price at 4000 USD trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() btc_available_funds = producer._get_available_funds("BTC") usd_available_funds = producer._get_available_funds("USD") # btc_available_funds for reduced because orders are not created assert btc_available_funds < 1 assert usd_available_funds < 400 # price info: create trades assert producer.current_price == 4000 assert producer.state == trading_enums.EvaluatorStates.NEUTRAL await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) pf_btc_available_funds = trading_api.get_portfolio_currency(exchange_manager, "BTC").available pf_usd_available_funds = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert pf_btc_available_funds >= 9 assert pf_usd_available_funds >= 600 # - 9 to make it as if itr was starting with 1 btc (to compare with btc_available_funds) assert pf_btc_available_funds - 9 >= btc_available_funds # - 600 to make it as if itr was starting with 1 btc (to compare with btc_available_funds) assert pf_usd_available_funds - 600 >= usd_available_funds async def test_get_maximum_traded_funds(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools # part 1: no available funds set # no allowed_funds set # can trade total_available_funds assert producer._get_maximum_traded_funds(0, 10, "BTC", True, False) == 10 == decimal.Decimal(10) # allowed_funds set # can trade allowed_funds assert producer._get_maximum_traded_funds(5, 10, "BTC", False, False) == 5 # allowed_funds set, allowed_funds larger than total_available_funds # can trade total_available_funds assert producer._get_maximum_traded_funds(15, 10, "BTC", True, False) == 10 # part 2: available funds set is set producer._set_initially_available_funds("BTC", decimal.Decimal(8)) # no allowed_funds set # can trade available funds only assert producer._get_maximum_traded_funds(0, 10, "BTC", False, False) == 8 # allowed_funds set # can trade allowed_funds (lower than available funds) assert producer._get_maximum_traded_funds(5, 10, "BTC", True, False) == 5 # allowed_funds set, allowed_funds larger than total_available_funds # can trade available funds only assert producer._get_maximum_traded_funds(15, 10, "BTC", False, False) == 8 async def test_get_new_state_price(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.current_price = 4000 assert producer._get_new_state_price() == 4000 producer.starting_price = 2 assert producer._get_new_state_price() == 2 async def test_set_increment_and_spread(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market assert producer.flat_increment is None assert producer.flat_spread is None producer._set_increment_and_spread(1000) assert producer.flat_increment == 1000 * producer.increment assert producer.flat_spread == 1000 * producer.spread producer._set_increment_and_spread(2000) # no change: producer.flat_increment and producer.flat_spread are not None assert producer.flat_increment == 1000 * producer.increment assert producer.flat_spread == 1000 * producer.spread # reset producer.flat_increment = None producer.flat_spread = None # use candidate_flat_increment producer._set_increment_and_spread(3000, candidate_flat_increment=500) assert producer.flat_increment == 500 assert producer.flat_spread == 500 * producer.spread / producer.increment async def test_use_existing_orders_only(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer.use_existing_orders_only = True assert producer.flat_increment is None assert producer.flat_spread is None with mock.patch.object(producer, '_create_order', new=mock.AsyncMock()) as mocked_producer_create_order: trading_api.force_set_mark_price(exchange_manager, symbol, 4000) await producer._ensure_staggered_orders() # price info: create trades assert producer.current_price == 4000 assert producer.state == trading_enums.EvaluatorStates.NEUTRAL mocked_producer_create_order.assert_not_called() assert producer.flat_increment is not None assert producer.flat_spread is not None await asyncio.create_task(_wait_for_orders_creation(2)) # did not create orders assert not trading_api.get_open_orders(exchange_manager) async def test_create_orders_without_existing_orders_symmetrical_case_all_modes_price_100(): price = 100 await _test_mode(staggered_orders_trading.StrategyModes.NEUTRAL, 25, 2475, price) await _test_mode(staggered_orders_trading.StrategyModes.MOUNTAIN, 25, 2475, price) await _test_mode(staggered_orders_trading.StrategyModes.VALLEY, 25, 2475, price) await _test_mode(staggered_orders_trading.StrategyModes.BUY_SLOPE, 25, 2475, price) await _test_mode(staggered_orders_trading.StrategyModes.SELL_SLOPE, 25, 2475, price) await _test_mode(staggered_orders_trading.StrategyModes.FLAT, 25, 2475, price) async def test_create_orders_without_existing_orders_symmetrical_case_all_modes_price_347(): price = 347 await _test_mode(staggered_orders_trading.StrategyModes.NEUTRAL, 25, 695, price) await _test_mode(staggered_orders_trading.StrategyModes.MOUNTAIN, 25, 695, price) await _test_mode(staggered_orders_trading.StrategyModes.VALLEY, 25, 695, price) await _test_mode(staggered_orders_trading.StrategyModes.BUY_SLOPE, 25, 695, price) await _test_mode(staggered_orders_trading.StrategyModes.SELL_SLOPE, 25, 695, price) await _test_mode(staggered_orders_trading.StrategyModes.FLAT, 25, 695, price) async def test_create_orders_without_existing_orders_symmetrical_case_all_modes_price_0_347(): price = 0.347 lowest_buy = 0.001 highest_sell = 400 btc_holdings = 400 await _test_mode(staggered_orders_trading.StrategyModes.NEUTRAL, 25, 28793, price, lowest_buy, highest_sell, btc_holdings) await _test_mode(staggered_orders_trading.StrategyModes.MOUNTAIN, 25, 28793, price, lowest_buy, highest_sell, btc_holdings) await _test_mode(staggered_orders_trading.StrategyModes.VALLEY, 25, 28793, price, lowest_buy, highest_sell, btc_holdings) await _test_mode(staggered_orders_trading.StrategyModes.BUY_SLOPE, 25, 28793, price, lowest_buy, highest_sell, btc_holdings) await _test_mode(staggered_orders_trading.StrategyModes.SELL_SLOPE, 25, 28793, price, lowest_buy, highest_sell, btc_holdings) await _test_mode(staggered_orders_trading.StrategyModes.FLAT, 25, 28793, price, lowest_buy, highest_sell, btc_holdings) async def test_create_orders_from_different_markets(): async with _get_tools("BTC/USD", additional_portfolio={"RDN": 6740, "ETH": 10}) as tools: producer, _, exchange_manager = tools producer.symbol = "RDN/ETH" price = 0.0024161 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer.current_price = decimal.Decimal(str(price)) producer._refresh_symbol_data(symbol_market) producer.min_max_order_details[producer.min_cost] = decimal.Decimal(str(0.01)) producer.min_max_order_details[producer.min_quantity] = decimal.Decimal(str(1.0)) producer.min_max_order_details[producer.max_quantity] = decimal.Decimal(str(90000000.0)) producer.min_max_order_details[producer.max_cost] = None producer.min_max_order_details[producer.max_price] = None producer.min_max_order_details[producer.min_price] = None # await _test_mode(staggered_orders_trading.StrategyModes.NEUTRAL, 0, 0, price) lowest_buy = 0.0013 highest_sell = 0.0043 expected_buy_count = 46 expected_sell_count = 78 producer.lowest_buy = decimal.Decimal(str(lowest_buy)) producer.highest_sell = decimal.Decimal(str(highest_sell)) producer.increment = decimal.Decimal(str(0.01)) producer.spread = decimal.Decimal(str(0.01)) producer.operational_depth = 10 producer.final_eval = price producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN await _light_check_orders(producer, exchange_manager, expected_buy_count, expected_sell_count, price) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == producer.operational_depth # test trigger refresh trading_api.force_set_mark_price(exchange_manager, producer.symbol, 0.0024161) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) # did nothing assert original_orders[0] is trading_api.get_open_orders(exchange_manager)[0] assert original_orders[-1] is trading_api.get_open_orders(exchange_manager)[-1] assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth async def test_create_orders_from_different_very_close_refresh(): async with _get_tools("BTC/USD", additional_portfolio={"RDN": 6740, "ETH": 10}) as tools: producer, _, exchange_manager = tools producer.symbol = "RDN/ETH" price = 0.00231 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer.current_price = price producer._refresh_symbol_data(symbol_market) producer.min_max_order_details[producer.min_cost] = decimal.Decimal(str(0.01)) producer.min_max_order_details[producer.min_quantity] = decimal.Decimal(str(1.0)) producer.min_max_order_details[producer.max_quantity] = decimal.Decimal(str(90000000.0)) producer.min_max_order_details[producer.max_cost] = None producer.min_max_order_details[producer.max_price] = None producer.min_max_order_details[producer.min_price] = None # await _test_mode(staggered_orders_trading.StrategyModes.NEUTRAL, 0, 0, price) lowest_buy = 0.00221 highest_sell = 0.00242 expected_buy_count = 2 expected_sell_count = 2 producer.lowest_buy = decimal.Decimal(str(lowest_buy)) producer.highest_sell = decimal.Decimal(str(highest_sell)) producer.increment = decimal.Decimal(str(0.02)) producer.spread = decimal.Decimal(str(0.02)) producer.operational_depth = 10 producer.final_eval = price producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN await _light_check_orders(producer, exchange_manager, expected_buy_count, expected_sell_count, price) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) original_length = len(original_orders) # test trigger refresh trading_api.force_set_mark_price(exchange_manager, producer.symbol, 0.0023185) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) # did nothing assert original_orders[0] is trading_api.get_open_orders(exchange_manager)[0] assert original_orders[-1] is trading_api.get_open_orders(exchange_manager)[-1] assert original_length == len(trading_api.get_open_orders(exchange_manager)) # test more trigger refresh trading_api.force_set_mark_price(exchange_manager, producer.symbol, 0.0022991) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) # did nothing assert original_orders[0] is trading_api.get_open_orders(exchange_manager)[0] assert original_orders[-1] is trading_api.get_open_orders(exchange_manager)[-1] assert original_length == len(trading_api.get_open_orders(exchange_manager)) async def test_create_orders_from_different_markets_not_enough_market_to_create_all_orders(): async with _get_tools("BTC/USD", additional_portfolio={"RDN": 6740, "ETH": 10}) as tools: producer, _, exchange_manager = tools producer.symbol = "RDN/ETH" price = 0.0024161 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer.current_price = price producer._refresh_symbol_data(symbol_market) producer.min_max_order_details[producer.min_cost] = decimal.Decimal(str(1.0)) producer.min_max_order_details[producer.min_quantity] = decimal.Decimal(str(1.0)) producer.min_max_order_details[producer.max_quantity] = decimal.Decimal(str(90000000.0)) producer.min_max_order_details[producer.max_cost] = None producer.min_max_order_details[producer.max_price] = None producer.min_max_order_details[producer.min_price] = None # await _test_mode(staggered_orders_trading.StrategyModes.NEUTRAL, 0, 0, price) lowest_buy = 0.0013 highest_sell = 0.0043 expected_buy_count = 0 expected_sell_count = 0 producer.lowest_buy = decimal.Decimal(str(lowest_buy)) producer.highest_sell = decimal.Decimal(str(highest_sell)) producer.increment = decimal.Decimal(str(0.01)) producer.spread = decimal.Decimal(str(0.01)) producer.operational_depth = 10 producer.final_eval = price producer.mode = staggered_orders_trading.StrategyModes.MOUNTAIN await _light_check_orders(producer, exchange_manager, expected_buy_count, expected_sell_count, price) async def test_start_with_existing_valid_orders(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == producer.operational_depth # new evaluation, same price price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() # did nothing await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) assert original_orders[0] is trading_api.get_open_orders(exchange_manager)[0] assert original_orders[-1] is trading_api.get_open_orders(exchange_manager)[-1] assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth first_buy_index = int(len(trading_api.get_open_orders(exchange_manager)) / 2) # new evaluation, price changed # -2 order would be filled to_fill_order = original_orders[first_buy_index] price = 95 await _fill_order(to_fill_order, exchange_manager, price, producer=producer) await asyncio.create_task(_wait_for_orders_creation(2)) # did nothing: orders got replaced assert len(original_orders) == len(trading_api.get_open_orders(exchange_manager)) trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() # did nothing assert len(original_orders) == len(trading_api.get_open_orders(exchange_manager)) # orders gets cancelled open_orders = trading_api.get_open_orders(exchange_manager) to_cancel = [open_orders[20], open_orders[18], open_orders[3]] for order in to_cancel: await exchange_manager.trader.cancel_order(order) post_available = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(to_cancel) producer.RECENT_TRADES_ALLOWED_TIME = -1 await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) # restored orders assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USD").available <= post_available assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available async def test_price_initially_out_of_range_1(): async with _get_tools("BTC/USD", btc_holdings=100000000) as tools: producer, _, exchange_manager = tools # new evaluation: price in range # ~300k sell orders, 0 buy orders price = 0.8 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == producer.operational_depth assert all(o.side == trading_enums.TradeOrderSide.SELL for o in original_orders) assert all(producer.highest_sell >= o.origin_price >= producer.lowest_buy for o in original_orders) async def test_price_initially_out_of_range_2(): async with _get_tools("BTC/USD", btc_holdings=10000000) as tools: producer, _, exchange_manager = tools # new evaluation: price in range price = 100000 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == 2 assert all(o.side == trading_enums.TradeOrderSide.BUY for o in original_orders) assert all(producer.highest_sell >= o.origin_price >= producer.lowest_buy for o in original_orders) async def test_price_going_out_of_range(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools # new evaluation: price in range price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) # new evaluation: price out of range: > price = 100000 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) producer.current_price = price existing_orders = trading_api.get_open_orders(exchange_manager) sorted_orders = sorted(existing_orders, key=lambda order: order.origin_price) missing_orders, state, candidate_flat_increment = producer._analyse_current_orders_situation( sorted_orders, [], sorted_orders[0].origin_price, sorted_orders[-1].origin_price, price ) assert missing_orders is None assert candidate_flat_increment is None assert state == producer.ERROR # new evaluation: price out of range: < price = 0.1 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) producer.current_price = price existing_orders = trading_api.get_open_orders(exchange_manager) sorted_orders = sorted(existing_orders, key=lambda order: order.origin_price) missing_orders, state, candidate_flat_increment = producer._analyse_current_orders_situation( sorted_orders, [], sorted_orders[0].origin_price, sorted_orders[-1].origin_price, price ) assert missing_orders is None assert candidate_flat_increment is None assert state == producer.ERROR async def test_start_after_offline_filled_orders(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools # first start: setup orders price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) original_orders = copy.copy(trading_api.get_open_orders(exchange_manager)) assert len(original_orders) == producer.operational_depth pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USD").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if 90 <= o.origin_price <= 110] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(offline_filled) # back online: restore orders according to current price price = 96 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) # force not use recent trades producer.RECENT_TRADES_ALLOWED_TIME = -1 # force funds reset in this test with mock.patch.object( producer, "_ensure_full_funds_usage", side_effect=staggered_orders_trading.ForceResetOrdersException ) as mock_ensure_full_funds_usage: await producer._ensure_staggered_orders() mock_ensure_full_funds_usage.assert_called_once() # restored orders await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USD").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available async def test_health_check_during_filled_orders(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools # first start: setup orders price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USD").available # offline simulation: orders get filled but not replaced => price got up to 110 and not down to 90, now is 96s open_orders = trading_api.get_open_orders(exchange_manager) offline_filled = [o for o in open_orders if 90 <= o.origin_price <= 110] for order in offline_filled: await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer) post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert pre_portfolio < post_portfolio assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(offline_filled) # back online: restore orders according to current price price = 96 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() # did not restore orders: they are being closed and callback will proceed (considered as recently closed # and consumer in queue) await asyncio.create_task( _check_open_orders_count(exchange_manager, producer.operational_depth - len(offline_filled))) assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "USD").available <= post_portfolio assert 0 <= trading_api.get_portfolio_currency(exchange_manager, "BTC").available async def test_compute_minimum_funds_1(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools # first start: setup orders buy_min_funds = producer._get_min_funds(decimal.Decimal(str(25)), decimal.Decimal(str(0.001)), staggered_orders_trading.StrategyModes.MOUNTAIN, decimal.Decimal(100)) sell_min_funds = producer._get_min_funds(decimal.Decimal(str(2475.25)), decimal.Decimal(str(0.00001)), staggered_orders_trading.StrategyModes.MOUNTAIN, decimal.Decimal(100)) assert buy_min_funds == decimal.Decimal(str(0.05)) * staggered_orders_trading.TEN_PERCENT_DECIMAL assert sell_min_funds == decimal.Decimal(str(0.049505)) * staggered_orders_trading.TEN_PERCENT_DECIMAL exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USD").available = buy_min_funds exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USD").total = buy_min_funds exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = sell_min_funds exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = sell_min_funds price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len(orders) == producer.operational_depth assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 25 assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.BUY]) == 25 async def test_compute_minimum_funds_2(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools # first start: setup orders _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer._refresh_symbol_data(symbol_market) buy_min_funds = producer._get_min_funds(decimal.Decimal(str(25)), decimal.Decimal(str(0.001)), staggered_orders_trading.StrategyModes.MOUNTAIN, decimal.Decimal(str(100))) sell_min_funds = producer._get_min_funds(decimal.Decimal(str(2475)), decimal.Decimal(str(0.00001)), staggered_orders_trading.StrategyModes.MOUNTAIN, decimal.Decimal(str(100))) assert buy_min_funds == decimal.Decimal(str(0.05)) * staggered_orders_trading.TEN_PERCENT_DECIMAL assert sell_min_funds == decimal.Decimal(str(0.0495)) * staggered_orders_trading.TEN_PERCENT_DECIMAL exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USD").available = buy_min_funds * decimal.Decimal("0.99999") exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("USD").total = buy_min_funds * decimal.Decimal("0.99999") exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").available = sell_min_funds * decimal.Decimal("0.99999") exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio("BTC").total = sell_min_funds * decimal.Decimal("0.99999") price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, 0)) async def test_start_without_enough_funds_to_buy(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( "USD").available = decimal.Decimal("0.00005") exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( "USD").total = decimal.Decimal("0.00005") price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len(orders) == producer.operational_depth assert all([o.side == trading_enums.TradeOrderSide.SELL for o in orders]) # trigger health check await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) await _fill_order(orders[5], exchange_manager, producer=producer) async def test_start_without_enough_funds_to_sell(): async with _get_tools("BTC/USD", btc_holdings=0.00001) as tools: producer, _, exchange_manager = tools price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len(orders) == 25 assert all([o.side == trading_enums.TradeOrderSide.BUY for o in orders]) # trigger health check await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) # check order fill callback recreates spread to_fill_order = orders[5] second_to_fill_order = orders[4] await _fill_order(to_fill_order, exchange_manager, producer=producer) await asyncio.create_task(_wait_for_orders_creation(2)) orders = trading_api.get_open_orders(exchange_manager) newly_created_sell_order = orders[-1] assert newly_created_sell_order.side == trading_enums.TradeOrderSide.SELL assert newly_created_sell_order.origin_price == to_fill_order.origin_price + \ producer.flat_spread - producer.flat_increment await _fill_order(second_to_fill_order, exchange_manager, producer=producer) await asyncio.create_task(_wait_for_orders_creation(2)) orders = trading_api.get_open_orders(exchange_manager) second_newly_created_sell_order = orders[-1] assert second_newly_created_sell_order.side == trading_enums.TradeOrderSide.SELL assert second_newly_created_sell_order.origin_price == second_to_fill_order.origin_price + \ producer.flat_spread - producer.flat_increment assert abs(second_newly_created_sell_order.origin_price - newly_created_sell_order.origin_price) == \ producer.flat_increment async def test_start_without_enough_funds_at_all(): async with _get_tools("BTC/USD", btc_holdings=0.00001) as tools: producer, _, exchange_manager = tools exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( "USD").available = decimal.Decimal("0.00005") exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio( "USD").total = decimal.Decimal("0.00005") price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, 0)) async def test_settings_for_just_one_order_on_a_side(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools producer.highest_sell = 106 price = 100 trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) orders = trading_api.get_open_orders(exchange_manager) assert len([o for o in orders if o.side == trading_enums.TradeOrderSide.SELL]) == 1 async def test_order_fill_callback(): async with _get_tools("BTC/USD", fees=0) as tools: producer, _, exchange_manager = tools # create orders price = 100 producer.mode = staggered_orders_trading.StrategyModes.NEUTRAL trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) previous_total = _get_total_usd(exchange_manager, 100) now_btc = trading_api.get_portfolio_currency(exchange_manager, "BTC").total now_usd = trading_api.get_portfolio_currency(exchange_manager, "USD").total await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) price_increment = producer.flat_increment price_spread = producer.flat_spread open_orders = trading_api.get_open_orders(exchange_manager) assert len(open_orders) == producer.operational_depth # closest to centre buy order is filled => bought btc to_fill_order = open_orders[-2] await _fill_order(to_fill_order, exchange_manager, producer=producer) open_orders = trading_api.get_open_orders(exchange_manager) # instantly create sell order at price * (1 + increment) assert len(open_orders) == producer.operational_depth assert to_fill_order not in open_orders newly_created_sell_order = open_orders[-1] assert newly_created_sell_order.associated_entry_ids == [to_fill_order.order_id] assert newly_created_sell_order.symbol == to_fill_order.symbol price = to_fill_order.origin_price + (price_spread - price_increment) assert newly_created_sell_order.origin_price == trading_personal_data.decimal_trunc_with_n_decimal_digits(price, 8) assert newly_created_sell_order.origin_quantity == \ trading_personal_data.decimal_trunc_with_n_decimal_digits( to_fill_order.filled_quantity * (1 - producer.max_fees),8) assert newly_created_sell_order.side == trading_enums.TradeOrderSide.SELL assert trading_api.get_portfolio_currency(exchange_manager, "BTC").total > now_btc now_btc = trading_api.get_portfolio_currency(exchange_manager, "BTC").total current_total = _get_total_usd(exchange_manager, 100) assert previous_total < current_total previous_total_buy = current_total # now this new sell order is filled => sold btc to_fill_order = open_orders[-1] await _fill_order(to_fill_order, exchange_manager, producer=producer) open_orders = trading_api.get_open_orders(exchange_manager) # instantly create buy order at price * (1 + increment) assert len(open_orders) == producer.operational_depth assert to_fill_order not in open_orders newly_created_buy_order = open_orders[-1] assert newly_created_buy_order.associated_entry_ids is None # buy order => previous sell order is not an entry assert newly_created_buy_order.symbol == to_fill_order.symbol price = to_fill_order.origin_price - (price_spread - price_increment) assert newly_created_buy_order.origin_price == trading_personal_data.decimal_trunc_with_n_decimal_digits(price, 8) assert newly_created_buy_order.origin_quantity == \ trading_personal_data.decimal_trunc_with_n_decimal_digits( to_fill_order.filled_price / price * to_fill_order.filled_quantity * (1 - producer.max_fees), 8) assert newly_created_buy_order.side == trading_enums.TradeOrderSide.BUY assert trading_api.get_portfolio_currency(exchange_manager, "USD").total > now_usd now_usd = trading_api.get_portfolio_currency(exchange_manager, "USD").total current_total = _get_total_usd(exchange_manager, 100) assert previous_total < current_total previous_total_sell = current_total # now this new buy order is filled => bought btc to_fill_order = open_orders[-1] await _fill_order(to_fill_order, exchange_manager, producer=producer) open_orders = trading_api.get_open_orders(exchange_manager) # instantly create sell order at price * (1 + increment) assert len(open_orders) == producer.operational_depth assert to_fill_order not in open_orders newly_created_sell_order = open_orders[-1] assert newly_created_sell_order.associated_entry_ids == [to_fill_order.order_id] assert newly_created_sell_order.symbol == to_fill_order.symbol price = to_fill_order.origin_price + (price_spread - price_increment) assert newly_created_sell_order.origin_price == trading_personal_data.decimal_trunc_with_n_decimal_digits(price, 8) assert newly_created_sell_order.origin_quantity == \ trading_personal_data.decimal_trunc_with_n_decimal_digits( to_fill_order.filled_quantity * (1 - producer.max_fees), 8) assert newly_created_sell_order.side == trading_enums.TradeOrderSide.SELL assert trading_api.get_portfolio_currency(exchange_manager, "BTC").total > now_btc current_total = _get_total_usd(exchange_manager, 100) assert previous_total_buy < current_total # now this new sell order is filled => sold btc to_fill_order = open_orders[-1] await _fill_order(to_fill_order, exchange_manager, producer=producer) open_orders = trading_api.get_open_orders(exchange_manager) # instantly create buy order at price * (1 + increment) assert len(open_orders) == producer.operational_depth assert to_fill_order not in open_orders newly_created_buy_order = open_orders[-1] assert newly_created_buy_order.associated_entry_ids is None # buy order => previous sell order is not an entry assert newly_created_buy_order.symbol == to_fill_order.symbol price = to_fill_order.origin_price - (price_spread - price_increment) assert newly_created_buy_order.origin_price == trading_personal_data.decimal_trunc_with_n_decimal_digits(price, 8) assert newly_created_buy_order.origin_quantity == \ trading_personal_data.decimal_trunc_with_n_decimal_digits( to_fill_order.filled_price / price * to_fill_order.filled_quantity * (1 - producer.max_fees), 8) assert newly_created_buy_order.side == trading_enums.TradeOrderSide.BUY assert trading_api.get_portfolio_currency(exchange_manager, "USD").total > now_usd current_total = _get_total_usd(exchange_manager, 100) assert previous_total_sell < current_total async def test_order_fill_callback_with_mirror_delay(): async with _get_tools("BTC/USD", fees=0) as tools: producer, _, exchange_manager = tools # create orders price = 100 producer.mode = staggered_orders_trading.StrategyModes.NEUTRAL trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) open_orders = trading_api.get_open_orders(exchange_manager) assert len(open_orders) == producer.operational_depth # closest to centre buy order is filled => bought btc producer.mirror_order_delay = 0.1 to_fill_order = open_orders[-2] in_backtesting = "tentacles.Trading.Mode.staggered_orders_trading_mode.staggered_orders_trading.trading_api.get_is_backtesting" with mock.patch(in_backtesting, return_value=False), \ mock.patch.object(producer, "_create_order") as producer_create_order_mock: await _fill_order(to_fill_order, exchange_manager, producer=producer) assert len(producer.mirror_orders_tasks) producer_create_order_mock.assert_not_called() await asyncio.sleep(0.05) producer_create_order_mock.assert_not_called() await asyncio.sleep(0.1) producer_create_order_mock.assert_called_once() async def test_compute_mirror_order_volume(): async with _get_tools("BTC/USD", fees=0) as tools: producer, _, exchange_manager = tools # no ignore_exchange_fees # no fixed volumes producer.ignore_exchange_fees = False # 1% max fees producer.max_fees = decimal.Decimal("0.01") # take exchange fees into account assert producer._compute_mirror_order_volume( True, decimal.Decimal("100"), decimal.Decimal("120"), decimal.Decimal("2"), None ) == 2 * (1 - producer.max_fees) assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), {} ) == 2 * (decimal.Decimal("100") / decimal.Decimal("80")) * (1 - producer.max_fees) # with given fees fees = { trading_enums.FeePropertyColumns.COST.value: decimal.Decimal("0.032"), trading_enums.FeePropertyColumns.CURRENCY.value: "BTC" } assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), fees ) == 2 * (decimal.Decimal("100") / decimal.Decimal("80")) - decimal.Decimal("0.032") fees = { trading_enums.FeePropertyColumns.COST.value: decimal.Decimal("2.3"), trading_enums.FeePropertyColumns.CURRENCY.value: "USD" } assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), fees ) == 2 * (decimal.Decimal("100") / decimal.Decimal("80")) - (decimal.Decimal("2.3") / decimal.Decimal("100")) # with ignore_exchange_fees producer.ignore_exchange_fees = True # consider fees already taken, sell everything assert producer._compute_mirror_order_volume( True, decimal.Decimal("100"), decimal.Decimal("120"), decimal.Decimal("2"), None ) == 2 assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), {} ) == 2 * (decimal.Decimal("100") / decimal.Decimal("80")) assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), fees ) == 2 * (decimal.Decimal("100") / decimal.Decimal("80")) # with fixed volumes producer.ignore_exchange_fees = False producer.sell_volume_per_order = 3 # consider fees already taken, sell everything assert producer._compute_mirror_order_volume( True, decimal.Decimal("100"), decimal.Decimal("120"), decimal.Decimal("2"), fees ) == 3 # buy order assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), None ) == 2 * (decimal.Decimal("100") / decimal.Decimal("80")) * (1 - producer.max_fees) producer.buy_volume_per_order = 5 assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), {} ) == 5 # with fixed volumes and ignore_exchange_fees producer.ignore_exchange_fees = True assert producer._compute_mirror_order_volume( True, decimal.Decimal("100"), decimal.Decimal("120"), decimal.Decimal("2"), None ) == 3 assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), {} ) == 5 assert producer._compute_mirror_order_volume( False, decimal.Decimal("100"), decimal.Decimal("80"), decimal.Decimal("2"), fees ) == 5 async def test_create_order(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, consumer, exchange_manager = tools _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer._refresh_symbol_data(symbol_market) _origin_decimal_adapt_order_quantity_because_fees = trading_personal_data.decimal_adapt_order_quantity_because_fees def _decimal_adapt_order_quantity_because_fees( exchange_manager, symbol: str, order_type: trading_enums.TraderOrderType, quantity: decimal.Decimal, price: decimal.Decimal, side: trading_enums.TradeOrderSide ): return quantity with mock.patch.object( trading_personal_data, "decimal_adapt_order_quantity_because_fees", mock.Mock(side_effect=_decimal_adapt_order_quantity_because_fees) ) as decimal_adapt_order_quantity_because_fees_mock, mock.patch.object( consumer.trading_mode, "create_order", mock.AsyncMock(wraps=consumer.trading_mode.create_order) ) as create_order_mock: # SELL # enough quantity in portfolio price = decimal.Decimal(100) quantity = decimal.Decimal(1) side = trading_enums.TradeOrderSide.SELL to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) created_order = (await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies))[0] create_order_mock.assert_called_once() # dependencies are passed to create_order assert create_order_mock.mock_calls[0].kwargs["dependencies"] == dependencies assert created_order.origin_quantity == quantity decimal_adapt_order_quantity_because_fees_mock.assert_called_with( exchange_manager, symbol, trading_enums.TraderOrderType.SELL_LIMIT, created_order.origin_quantity, created_order.origin_price, trading_enums.TradeOrderSide.SELL, ) decimal_adapt_order_quantity_because_fees_mock.reset_mock() # not enough quantity in portfolio price = decimal.Decimal(100) quantity = decimal.Decimal(10) side = trading_enums.TradeOrderSide.SELL to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_order = await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies) decimal_adapt_order_quantity_because_fees_mock.assert_called_with( exchange_manager, symbol, trading_enums.TraderOrderType.SELL_LIMIT, decimal.Decimal('10'), decimal.Decimal('100'), trading_enums.TradeOrderSide.SELL ) decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert created_order == [] # just enough quantity in portfolio price = decimal.Decimal(100) quantity = decimal.Decimal(9) side = trading_enums.TradeOrderSide.SELL to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_order = (await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies))[0] decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert created_order.origin_quantity == quantity assert trading_api.get_portfolio_currency(exchange_manager, "BTC").available == decimal.Decimal(0) # not enough quantity anymore price = decimal.Decimal(100) quantity = decimal.Decimal("0.0001") side = trading_enums.TradeOrderSide.SELL to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_orders = await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies) decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert trading_api.get_portfolio_currency(exchange_manager, "BTC").available == decimal.Decimal(0) assert created_orders == [] # enough quantity in portfolio after a small adaptation price = decimal.Decimal(100) quantity = decimal.Decimal(2.01) # will be adapted side = trading_enums.TradeOrderSide.SELL trading_api.get_portfolio_currency(exchange_manager, "BTC").available = decimal.Decimal("1.98") to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_orders = await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies) decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert trading_api.get_portfolio_currency(exchange_manager, "BTC").available == decimal.Decimal("0.03030001") assert created_orders[0].origin_quantity == decimal.Decimal("1.94969999") # BUY # enough quantity in portfolio price = decimal.Decimal(100) quantity = decimal.Decimal(1) side = trading_enums.TradeOrderSide.BUY to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_order = (await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies))[0] decimal_adapt_order_quantity_because_fees_mock.assert_called_with( exchange_manager, symbol, trading_enums.TraderOrderType.BUY_LIMIT, created_order.origin_quantity, created_order.origin_price, trading_enums.TradeOrderSide.BUY, ) decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert created_order.origin_quantity == quantity assert trading_api.get_portfolio_currency(exchange_manager, "USD").available == 900 assert created_order is not None # not enough quantity in portfolio price = decimal.Decimal(585) quantity = decimal.Decimal(2) side = trading_enums.TradeOrderSide.BUY to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_orders = await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies) decimal_adapt_order_quantity_because_fees_mock.assert_called_with( exchange_manager, symbol, trading_enums.TraderOrderType.BUY_LIMIT, decimal.Decimal('2'), decimal.Decimal('585'), trading_enums.TradeOrderSide.BUY ) decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert trading_api.get_portfolio_currency(exchange_manager, "USD").available == 900 assert created_orders == [] # enough quantity in portfolio price = decimal.Decimal(40) quantity = decimal.Decimal(2) side = trading_enums.TradeOrderSide.BUY to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_order = (await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies))[0] decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert created_order.origin_quantity == quantity assert trading_api.get_portfolio_currency(exchange_manager, "USD").available == 820 # enough quantity in portfolio price = decimal.Decimal(205) quantity = decimal.Decimal(4) side = trading_enums.TradeOrderSide.BUY to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_order = (await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies))[0] decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert created_order.origin_quantity == quantity assert trading_api.get_portfolio_currency(exchange_manager, "USD").available == 0 assert created_order is not None # not enough quantity in portfolio anymore price = decimal.Decimal(205) quantity = decimal.Decimal(1) side = trading_enums.TradeOrderSide.BUY to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_orders = await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies) decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert trading_api.get_portfolio_currency(exchange_manager, "USD").available == 0 assert created_orders == [] # enough quantity in portfolio after a small adaptation price = decimal.Decimal(100) quantity = decimal.Decimal(1.01) # will be adapted to 1 side = trading_enums.TradeOrderSide.BUY trading_api.get_portfolio_currency(exchange_manager, "USD").available = decimal.Decimal("100") to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) created_orders = await consumer.create_order(to_create_order, price, symbol_market, dependencies=dependencies) decimal_adapt_order_quantity_because_fees_mock.assert_called_once() decimal_adapt_order_quantity_because_fees_mock.reset_mock() assert trading_api.get_portfolio_currency(exchange_manager, "USD").available == decimal.Decimal("2.03") assert created_orders[0].origin_quantity == decimal.Decimal("0.97970000") async def test_create_state(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, consumer, exchange_manager = tools price = decimal.Decimal(1000) ignore_mirror_orders_only = False ignore_available_funds = False trigger_trailing = False _, _, _, _, producer.symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol) # not triggering trailing dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], False, dependencies))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_awaited_once_with(price, ignore_available_funds, trigger_trailing) _create_not_virtual_orders_mock.assert_awaited_once_with([], price, False, dependencies) # triggering trailing with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], True, None))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_awaited_once_with(price, ignore_available_funds, trigger_trailing) _create_not_virtual_orders_mock.assert_awaited_once_with([], price, True, None) trigger_trailing = True with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], True, None))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_awaited_once_with(price, ignore_available_funds, trigger_trailing) _create_not_virtual_orders_mock.assert_awaited_once_with([], price, True, None) # already trailing: skip call producer.is_currently_trailing = True with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], True, None))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_not_called() _create_not_virtual_orders_mock.assert_not_called() with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], False, None))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_not_called() _create_not_virtual_orders_mock.assert_not_called() # not tailing anymore: can now call producer.is_currently_trailing = False with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], True, None))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_awaited_once_with(price, ignore_available_funds, trigger_trailing) _create_not_virtual_orders_mock.assert_awaited_once_with([], price, True, None) trigger_trailing = True with mock.patch.object(producer, "_generate_staggered_orders", mock.AsyncMock(return_value=([], [], False, None))) \ as _generate_staggered_orders_mock, mock.patch.object(producer, "_create_not_virtual_orders", mock.AsyncMock()) \ as _create_not_virtual_orders_mock: await producer.create_state(price, ignore_mirror_orders_only, ignore_available_funds, trigger_trailing) _generate_staggered_orders_mock.assert_awaited_once_with(price, ignore_available_funds, trigger_trailing) _create_not_virtual_orders_mock.assert_awaited_once_with([], price, False, None) async def test_create_new_orders(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, consumer, exchange_manager = tools _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=producer.symbol, timeout=1) producer.symbol_market = symbol_market producer._refresh_symbol_data(symbol_market) # valid input price = decimal.Decimal(205) quantity = decimal.Decimal(1) side = trading_enums.TradeOrderSide.BUY to_create_order = staggered_orders_trading.OrderData(side, quantity, price, symbol, False) producer.is_currently_trailing = True data = { consumer.ORDER_DATA_KEY: to_create_order, consumer.CURRENT_PRICE_KEY: price, consumer.SYMBOL_MARKET_KEY: symbol_market, consumer.COMPLETING_TRAILING_KEY: False, } dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) assert await consumer.create_new_orders(symbol, None, None, data=data, dependencies=dependencies) assert producer.is_currently_trailing is True data = { consumer.ORDER_DATA_KEY: to_create_order, consumer.CURRENT_PRICE_KEY: price, consumer.SYMBOL_MARKET_KEY: symbol_market, consumer.COMPLETING_TRAILING_KEY: True, # will update producer.is_currently_trailing } assert await consumer.create_new_orders(symbol, None, None, data=data, dependencies=dependencies) assert producer.is_currently_trailing is False # updated to false # invalid input 1 data = { consumer.ORDER_DATA_KEY: to_create_order, consumer.CURRENT_PRICE_KEY: price } with pytest.raises(KeyError): await consumer.create_new_orders(symbol, None, None, data=data, dependencies=None) # invalid input 2 data = {} with pytest.raises(KeyError): await consumer.create_new_orders(symbol, None, None, data=data) # invalid input 3 with pytest.raises(KeyError): await consumer.create_new_orders(symbol, None, None) async def test_ensure_current_price_in_limit_parameters(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.already_errored_on_out_of_window_price = False with mock.patch.object(producer, "_log_window_error_or_warning", mock.Mock()) \ as _log_window_error_or_warning_mock: # price too low (lower bound is 1) producer._ensure_current_price_in_limit_parameters(0.1) _log_window_error_or_warning_mock.assert_called_once() assert _log_window_error_or_warning_mock.mock_calls[0].args[1] is True _log_window_error_or_warning_mock.reset_mock() assert producer.already_errored_on_out_of_window_price is True producer._ensure_current_price_in_limit_parameters(0.1) assert _log_window_error_or_warning_mock.mock_calls[0].args[1] is False _log_window_error_or_warning_mock.reset_mock() assert producer.already_errored_on_out_of_window_price is True producer.already_errored_on_out_of_window_price = False # price too high (higher bound is 10000) producer._ensure_current_price_in_limit_parameters(999999) assert _log_window_error_or_warning_mock.mock_calls[0].args[1] is True _log_window_error_or_warning_mock.reset_mock() assert producer.already_errored_on_out_of_window_price is True producer._ensure_current_price_in_limit_parameters(999999) assert _log_window_error_or_warning_mock.mock_calls[0].args[1] is False _log_window_error_or_warning_mock.reset_mock() assert producer.already_errored_on_out_of_window_price is True async def test_single_exchange_process_optimize_initial_portfolio(): async with _get_tools("BTC/USD") as tools: producer, _, exchange_manager = tools mode = producer.trading_mode exchange_manager.exchange_config.traded_symbol_pairs = ["BTC/USD"] exchange_manager.client_symbols = ["BTC/USD"] initial_portfolio = exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio assert initial_portfolio["BTC"].available == decimal.Decimal("10") assert initial_portfolio["USD"].available == decimal.Decimal("1000") limit_buy = trading_personal_data.BuyLimitOrder(exchange_manager.trader) limit_buy.update(order_type=trading_enums.TraderOrderType.BUY_LIMIT, symbol="BTC/USD", current_price=decimal.Decimal(str(50)), quantity=decimal.Decimal(str(2)), price=decimal.Decimal(str(50))) await exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(limit_buy) orders = await mode.single_exchange_process_optimize_initial_portfolio( ["BTC", "ETH"], "USD", {"BTC/USD": {trading_enums.ExchangeConstantsTickersColumns.CLOSE.value: 1000}} ) cancelled_orders, part_1_orders, part_2_orders = [orders[0], orders[1], orders[2]] assert len(cancelled_orders) == 1 assert cancelled_orders[0] is limit_buy assert len(part_1_orders) == 1 part_1_order = part_1_orders[0] assert isinstance(part_1_order, trading_personal_data.SellMarketOrder) assert part_1_order.created_last_price == decimal.Decimal("1000") assert part_1_order.origin_quantity == decimal.Decimal("10") # 10 BTC to sell into 10 000 USD assert part_1_order.status == trading_enums.OrderStatus.FILLED assert part_2_orders part_2_order = part_2_orders[0] assert isinstance(part_2_order, trading_personal_data.BuyMarketOrder) assert part_2_order.created_last_price == decimal.Decimal("1000") assert part_2_order.origin_quantity == decimal.Decimal("5.545") # 50% of funds assert part_2_order.status == trading_enums.OrderStatus.FILLED # check portfolio is rebalanced final_portfolio = exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio assert final_portfolio["BTC"].available == decimal.Decimal('5.539455') # 5.545 - fees assert final_portfolio["USD"].available == decimal.Decimal("5545") async def test_prepare_trailing(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools with mock.patch.object( producer, "_prepare_order_by_order_trailing", mock.AsyncMock( return_value=(["_prepare_order_by_order_trailing"], [], [], [], None) ) ) as _prepare_order_by_order_trailing_mock, mock.patch.object( producer, "_prepare_full_grid_trailing", mock.AsyncMock( return_value=(["_prepare_full_grid_trailing"], [], [], [], None )) ) as _prepare_full_grid_trailing_mock: sorted_orders = [mock.Mock(side=trading_enums.TradeOrderSide.BUY)] recently_closed_trades = ["trades"] lowest_buy = decimal.Decimal(1) highest_buy = decimal.Decimal(2) lowest_sell = decimal.Decimal(3) highest_sell = decimal.Decimal(4) dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) log_header = "[binance] BTC/USD @ 1.5 full grid trailing up process: " # current_price can't be <= 0 for _current_price in [-1.5, 0]: current_price = decimal.Decimal(_current_price) assert await producer._prepare_trailing(sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, dependencies) == ( [], [], [], [], None ) _prepare_order_by_order_trailing_mock.assert_not_called() _prepare_full_grid_trailing_mock.assert_not_called() current_price = decimal.Decimal(1.5) producer.use_order_by_order_trailing = False assert await producer._prepare_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, dependencies ) == ( ["_prepare_full_grid_trailing"], [], [], [], None ) _prepare_order_by_order_trailing_mock.assert_not_called() _prepare_full_grid_trailing_mock.assert_awaited_once_with( sorted_orders, current_price, dependencies, log_header ) _prepare_full_grid_trailing_mock.reset_mock() sorted_orders = [mock.Mock(side=trading_enums.TradeOrderSide.SELL)] log_header = "[binance] BTC/USD @ 1.5 order by order trailing down process: " producer.use_order_by_order_trailing = True assert await producer._prepare_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, dependencies ) == ( ["_prepare_order_by_order_trailing"], [], [], [], None ) _prepare_full_grid_trailing_mock.assert_not_called() _prepare_order_by_order_trailing_mock.assert_awaited_once_with( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, False, dependencies, log_header ) async def test_prepare_order_by_order_trailing(): symbol = "BTC/USD" sorted_orders = [mock.Mock(order_id="123", origin_price=decimal.Decimal(str(50)))] recently_closed_trades = ["trades"] lowest_buy = decimal.Decimal(1) highest_buy = decimal.Decimal(2) lowest_sell = decimal.Decimal(3) highest_sell = decimal.Decimal(4) dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) log_header = "[binance] BTC/USD @ 25 full grid trailing process: " current_price = decimal.Decimal(25) async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools replaced_buy_orders = [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal(str(price)), decimal.Decimal(str(amount)), symbol, False ) for price, amount in [ (10, 0.02), (15, 0.017), (20, 0.015), ] ] replaced_sell_orders = [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal(str(price)), decimal.Decimal(str(amount)), symbol, False ) for price, amount in [ (30, 0.013), (40, 0.01), ] ] to_cancel_orders_with_trailed_prices = [ (sorted_orders[0], decimal.Decimal(50)), ] to_execute_order_with_trailing_price = (replaced_buy_orders[0], decimal.Decimal(60)) convert_order = mock.Mock(order_id="123") trailing_buy_orders = [123] trailing_sell_orders = [456] cancelled_replaced_orders = [] cancelled_orders = [sorted_orders[0]] is_trailing_up = True with mock.patch.object( producer, "_compute_trailing_replaced_orders", mock.AsyncMock( return_value=(replaced_buy_orders, replaced_sell_orders) ) ) as _compute_trailing_replaced_orders_mock, mock.patch.object( producer, "_get_orders_to_replace_with_updated_price_for_trailing", mock.Mock( return_value=(to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price) ) ) as _get_orders_to_replace_with_updated_price_for_trailing_mock, mock.patch.object( producer, "_cancel_replaced_orders", mock.AsyncMock( return_value=(cancelled_replaced_orders, cancelled_orders, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])) ) ) as _cancel_replaced_orders_mock, mock.patch.object( producer, "_convert_order_funds", mock.AsyncMock( return_value=[convert_order] ) ) as _convert_order_funds_mock, mock.patch.object( producer, "_get_updated_trailing_orders", mock.Mock( return_value=(trailing_buy_orders, trailing_sell_orders) ) ) as _get_updated_trailing_orders, mock.patch.object( producer, "_prepare_full_grid_trailing", mock.AsyncMock( return_value=(["9"], ["123"], ["456"], ["789"], "plop") ) ) as _prepare_full_grid_trailing_orders: assert await producer._prepare_order_by_order_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, is_trailing_up, dependencies, log_header ) == ( [sorted_orders[0]], [convert_order], trailing_buy_orders, trailing_sell_orders, trading_signals.get_orders_dependencies([convert_order]) ) _compute_trailing_replaced_orders_mock.assert_awaited_once_with( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, True, log_header ) _get_orders_to_replace_with_updated_price_for_trailing_mock.assert_called_once_with( sorted_orders, replaced_buy_orders+replaced_sell_orders, current_price ) _cancel_replaced_orders_mock.assert_awaited_once_with([o[0] for o in to_cancel_orders_with_trailed_prices + [to_execute_order_with_trailing_price]], dependencies) _convert_order_funds_mock.assert_awaited_once_with( to_execute_order_with_trailing_price[0], current_price, dependencies, log_header ) _get_updated_trailing_orders.assert_called_once_with( replaced_buy_orders, replaced_sell_orders, cancelled_replaced_orders, to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price, current_price, is_trailing_up ) _prepare_full_grid_trailing_orders.assert_not_called() _compute_trailing_replaced_orders_mock.reset_mock() _get_orders_to_replace_with_updated_price_for_trailing_mock.reset_mock() _cancel_replaced_orders_mock.reset_mock() _convert_order_funds_mock.reset_mock() _get_updated_trailing_orders.reset_mock() # with _get_orders_to_replace_with_updated_price_for_trailing raising an error with mock.patch.object( producer, "_get_orders_to_replace_with_updated_price_for_trailing", mock.Mock( side_effect=ValueError("test") ) ) as _get_orders_to_replace_with_updated_price_for_trailing_mock: assert await producer._prepare_order_by_order_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, is_trailing_up, dependencies, log_header ) == ( [], [], [], [], None ) _compute_trailing_replaced_orders_mock.assert_awaited_once_with( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, True, log_header ) _get_orders_to_replace_with_updated_price_for_trailing_mock.assert_called_once_with( sorted_orders, replaced_buy_orders+replaced_sell_orders, current_price ) _prepare_full_grid_trailing_orders.assert_not_called() _compute_trailing_replaced_orders_mock.reset_mock() _get_orders_to_replace_with_updated_price_for_trailing_mock.reset_mock() _cancel_replaced_orders_mock.assert_not_called() _convert_order_funds_mock.assert_not_called() _get_updated_trailing_orders.assert_not_called() with mock.patch.object( producer, "_get_orders_to_replace_with_updated_price_for_trailing", mock.Mock( side_effect=staggered_orders_trading.TrailingAborted("test") ) ) as _get_orders_to_replace_with_updated_price_for_trailing_mock: assert await producer._prepare_order_by_order_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, is_trailing_up, dependencies, log_header ) == ( [], [], replaced_buy_orders, replaced_sell_orders, None ) _compute_trailing_replaced_orders_mock.assert_awaited_once_with( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, True, log_header ) _get_orders_to_replace_with_updated_price_for_trailing_mock.assert_called_once_with( sorted_orders, replaced_buy_orders+replaced_sell_orders, current_price ) _prepare_full_grid_trailing_orders.assert_not_called() _compute_trailing_replaced_orders_mock.reset_mock() _get_orders_to_replace_with_updated_price_for_trailing_mock.reset_mock() _cancel_replaced_orders_mock.assert_not_called() _convert_order_funds_mock.assert_not_called() _get_updated_trailing_orders.assert_not_called() with mock.patch.object( producer, "_get_orders_to_replace_with_updated_price_for_trailing", mock.Mock( side_effect=staggered_orders_trading.NoOrdersToTrail("test") ) ) as _get_orders_to_replace_with_updated_price_for_trailing_mock: assert await producer._prepare_order_by_order_trailing( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, is_trailing_up, dependencies, log_header ) == ( ["9"], ["123"], ["456"], ["789"], "plop" ) _compute_trailing_replaced_orders_mock.assert_awaited_once_with( sorted_orders, recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, True, log_header ) _get_orders_to_replace_with_updated_price_for_trailing_mock.assert_called_once_with( sorted_orders, replaced_buy_orders+replaced_sell_orders, current_price ) _prepare_full_grid_trailing_orders.assert_awaited_once_with( sorted_orders, current_price, dependencies, log_header ) _prepare_full_grid_trailing_orders.reset_mock() _compute_trailing_replaced_orders_mock.reset_mock() _get_orders_to_replace_with_updated_price_for_trailing_mock.reset_mock() _cancel_replaced_orders_mock.assert_not_called() _convert_order_funds_mock.assert_not_called() _get_updated_trailing_orders.assert_not_called() async def test_compute_trailing_replaced_orders(): symbol = "BTC/USD" recently_closed_trades = ["trades"] current_price = decimal.Decimal(400) lowest_buy = decimal.Decimal(1) highest_buy = current_price lowest_sell = current_price highest_sell = decimal.Decimal(10000) ignore_available_funds = "ignore_available_funds" log_header = "[binance] BTC/USD @ 25 order by order trailing process: " async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools with mock.patch.object( producer, "_handle_missed_mirror_orders_fills", mock.AsyncMock() ) as _handle_missed_mirror_orders_fills_mock: # no orders, raises NoOrdersToTrail with pytest.raises(staggered_orders_trading.NoOrdersToTrail): await producer._compute_trailing_replaced_orders( [], recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, log_header ) _handle_missed_mirror_orders_fills_mock.assert_not_called() with mock.patch.object( producer, "_analyse_current_orders_situation", mock.Mock(return_value=([], staggered_orders_trading.StaggeredOrdersTradingModeProducer.ERROR, None)) ) as _analyse_current_orders_situation_mock: with pytest.raises(ValueError): await producer._compute_trailing_replaced_orders( [], recently_closed_trades, lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, log_header ) _analyse_current_orders_situation_mock.reset_mock() _handle_missed_mirror_orders_fills_mock.assert_not_called() trading_api.force_set_mark_price(exchange_manager, producer.symbol, current_price) await producer._ensure_staggered_orders() await asyncio.create_task(_wait_for_orders_creation(producer.operational_depth)) open_orders = trading_api.get_open_orders(exchange_manager) assert len(open_orders) == producer.operational_depth sorted_orders = sorted(open_orders, key=lambda order: order.origin_price) highest_sell = sorted_orders[-1].origin_price # nothing to replace replaced_buy_orders, replaced_sell_orders = await producer._compute_trailing_replaced_orders( sorted_orders, [], lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, log_header ) assert replaced_buy_orders == replaced_sell_orders == [] _handle_missed_mirror_orders_fills_mock.assert_not_called() # with missing orders: returned as replaced orders input_open_orders = sorted_orders[:23] + sorted_orders[26:] # simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None) replaced_buy_orders, replaced_sell_orders = await producer._compute_trailing_replaced_orders( input_open_orders, [], lowest_buy, highest_buy, lowest_sell, highest_sell, current_price, ignore_available_funds, log_header ) assert replaced_buy_orders == [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal(str(amount)), decimal.Decimal(str(price)), symbol, False ) for price, amount in [ (372, "0.0583333333333333370"), (388, "0.0541666666666666630"), ] ] # 2 buy orders missing assert replaced_sell_orders == [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal(str(amount)), decimal.Decimal(str(price)), symbol, False ) for price, amount in [ (412, "0.2166666666666666520"), ] ] # 1 sell order missing missing_orders = [(decimal.Decimal(str(price)), trading_enums.TradeOrderSide.BUY) for price in [372, 388]] + [(decimal.Decimal(str(price)), trading_enums.TradeOrderSide.SELL) for price in [412]] _handle_missed_mirror_orders_fills_mock.assert_awaited_once_with( [], missing_orders, current_price ) async def test_cancel_replaced_orders(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools with mock.patch.object( producer, "_cancel_open_order", mock.AsyncMock(return_value=(True, trading_signals.get_orders_dependencies([mock.Mock(order_id="345")]))) ) as _cancel_open_order_mock: # 1. no replaced orders cancelled_replaced_orders, cancelled_orders, dependencies = await producer._cancel_replaced_orders( [], trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) _cancel_open_order_mock.assert_not_called() assert cancelled_replaced_orders == [] assert cancelled_orders == [] assert dependencies == commons_signals.SignalDependencies() # 2. replaced "real" orders replaced_orders = [mock.Mock(order_id="123"), mock.Mock(order_id="345")] cancelled_replaced_orders, cancelled_orders, dependencies = await producer._cancel_replaced_orders( replaced_orders, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) _cancel_open_order_mock.assert_has_calls([ mock.call(replaced_orders[0], trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])), mock.call(replaced_orders[1], trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])), ]) assert cancelled_replaced_orders == [] assert cancelled_orders == replaced_orders assert dependencies == trading_signals.get_orders_dependencies([mock.Mock(order_id="345"), mock.Mock(order_id="345")]) # 3. replaced "real" orders and "fake" orders replaced_orders = [mock.Mock(order_id="123"), staggered_orders_trading.OrderData(trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.01"), decimal.Decimal("100"), symbol, False)] cancelled_replaced_orders, cancelled_orders, dependencies = await producer._cancel_replaced_orders( replaced_orders, trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) ) _cancel_open_order_mock.assert_has_calls([ mock.call(replaced_orders[0], trading_signals.get_orders_dependencies([mock.Mock(order_id="123")])), ]) assert cancelled_replaced_orders == [replaced_orders[1]] assert cancelled_orders == [replaced_orders[0]] assert dependencies == trading_signals.get_orders_dependencies([mock.Mock(order_id="345")]) async def test_convert_order_funds(): symbol = "BTC/USD" log_header = "[binance] BTC/USD @ 25 convert order funds process: " convert_dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) quantity = decimal.Decimal("0.01") async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools current_price = decimal.Decimal(25) # Test 1: BUY order with trading_personal_data.Order buy_price = decimal.Decimal(10) to_convert_buy_order = trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.BUY_LIMIT, symbol, current_price, quantity, price=buy_price ) convert_orders = await producer._convert_order_funds( to_convert_buy_order, current_price, convert_dependencies, log_header ) assert len(convert_orders) == 1 assert isinstance(convert_orders[0], trading_personal_data.BuyMarketOrder) assert convert_orders[0].symbol == "BTC/USD" assert convert_orders[0].origin_quantity == decimal.Decimal("0.004") # 0.01 * 10 / 25 (buy order quantity x price / current price) assert convert_orders[0].origin_price == decimal.Decimal("25") assert convert_orders[0].total_cost == decimal.Decimal("0.1") # 0.01 * 10 (buy order quantity x price) assert convert_orders[0].status == trading_enums.OrderStatus.FILLED # Test 2: SELL order with trading_personal_data.Order sell_price = decimal.Decimal(30) to_convert_sell_order = trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.SELL_LIMIT, symbol, current_price, quantity, price=sell_price ) convert_orders = await producer._convert_order_funds( to_convert_sell_order, current_price, convert_dependencies, log_header ) assert len(convert_orders) == 1 assert isinstance(convert_orders[0], trading_personal_data.SellMarketOrder) assert convert_orders[0].symbol == "BTC/USD" assert convert_orders[0].origin_quantity == decimal.Decimal("0.01") assert convert_orders[0].origin_price == decimal.Decimal("25") assert convert_orders[0].total_cost == decimal.Decimal("0.25") # sell order quantity assert convert_orders[0].status == trading_enums.OrderStatus.FILLED # Test 3: BUY order with OrderData buy_price = decimal.Decimal(15) to_convert_buy_order_data = staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, quantity, buy_price, symbol, False ) convert_orders = await producer._convert_order_funds( to_convert_buy_order_data, current_price, convert_dependencies, log_header ) assert len(convert_orders) == 1 assert isinstance(convert_orders[0], trading_personal_data.BuyMarketOrder) assert convert_orders[0].symbol == "BTC/USD" assert convert_orders[0].origin_quantity == decimal.Decimal("0.006") # 0.01 * 10 / 15 (buy order quantity x price / current price) assert convert_orders[0].origin_price == decimal.Decimal("25") assert convert_orders[0].status == trading_enums.OrderStatus.FILLED # Test 4: SELL order with OrderData sell_price = decimal.Decimal(35) to_convert_sell_order_data = staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, quantity, sell_price, symbol, False ) convert_orders = await producer._convert_order_funds( to_convert_sell_order_data, current_price, convert_dependencies, log_header ) assert len(convert_orders) == 1 # Verify the convert order properties convert_order = convert_orders[0] assert isinstance(convert_orders[0], trading_personal_data.SellMarketOrder) assert convert_orders[0].symbol == "BTC/USD" assert convert_orders[0].origin_quantity == decimal.Decimal("0.01") assert convert_orders[0].origin_price == decimal.Decimal("25") assert convert_orders[0].status == trading_enums.OrderStatus.FILLED async def test_get_updated_trailing_orders(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools current_price = decimal.Decimal("25") # Test 1: to_convert_order with sufficient funds, trailing up to_convert_order = trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.BUY_LIMIT, symbol, current_price, decimal.Decimal("0.01"), price=decimal.Decimal("20") ) replaced_buy_orders = [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.02"), decimal.Decimal("15"), symbol, False ) ] replaced_sell_orders = [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.03"), decimal.Decimal("30"), symbol, False ) ] cancelled_replaced_orders = [] to_cancel_orders_with_trailed_prices = [ (replaced_buy_orders[0], decimal.Decimal("18")), (replaced_sell_orders[0], decimal.Decimal("28")) ] to_execute_order_with_trailing_price = (to_convert_order, decimal.Decimal("22")) trailing_buy_orders, trailing_sell_orders = producer._get_updated_trailing_orders( replaced_buy_orders, replaced_sell_orders, cancelled_replaced_orders, to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price, current_price, True ) # Verify results assert len(trailing_buy_orders) == 2 # 1 from replaced + 1 from to_cancel_orders_with_trailed_prices assert len(trailing_sell_orders) == 3 # 1 from replaced + 1 from to_cancel_orders_with_trailed_prices + 1 from to_convert_order assert trailing_buy_orders[0] is replaced_buy_orders[0] assert trailing_buy_orders[1] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.01666666666666666666666666667"), decimal.Decimal("18"), symbol, False ) assert trailing_sell_orders[0] is replaced_sell_orders[0] assert trailing_sell_orders[1] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.03"), decimal.Decimal("28"), symbol, False ) assert trailing_sell_orders[2] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.009090909090909090909090909091"), decimal.Decimal("22"), symbol, False ) assert trailing_sell_orders[2].quantity * trailing_sell_orders[2].price == to_convert_order.origin_price * to_convert_order.origin_quantity # Test 2: to_convert_order with sufficient funds and cancelled orders, trailing down to_convert_order = trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.SELL_LIMIT, symbol, current_price, decimal.Decimal("0.033"), price=decimal.Decimal("35") ) replaced_buy_orders = [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.02"), decimal.Decimal("15"), symbol, False ), staggered_orders_trading.OrderData( # cancelled trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.02"), decimal.Decimal("16"), symbol, False ), ] replaced_sell_orders = [ staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.03"), decimal.Decimal("30"), symbol, False ), staggered_orders_trading.OrderData( # cancelled trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.03"), decimal.Decimal("31"), symbol, False ), ] cancelled_replaced_orders = [replaced_buy_orders[1], replaced_sell_orders[1]] to_cancel_orders_with_trailed_prices = [ (replaced_buy_orders[0], decimal.Decimal("18")), (replaced_sell_orders[0], decimal.Decimal("28")) ] current_price = decimal.Decimal("22") # also ensure current price == convert order price is supported to_execute_order_with_trailing_price = (to_convert_order, decimal.Decimal("22")) trailing_buy_orders, trailing_sell_orders = producer._get_updated_trailing_orders( replaced_buy_orders, replaced_sell_orders, cancelled_replaced_orders, to_cancel_orders_with_trailed_prices, to_execute_order_with_trailing_price, current_price, False ) # Verify results assert len(trailing_buy_orders) == 3 # 1 from replaced + 1 from to_cancel_orders_with_trailed_prices + 1 from to_convert_order assert len(trailing_sell_orders) == 2 # 1 from replaced + 1 from to_cancel_orders_with_trailed_prices assert trailing_buy_orders[0] is replaced_buy_orders[0] assert trailing_buy_orders[1] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.01666666666666666666666666667"), decimal.Decimal("18"), symbol, False ) assert trailing_buy_orders[2] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.0525"), decimal.Decimal("22"), symbol, False ) assert trailing_buy_orders[2].quantity * trailing_buy_orders[2].price == to_convert_order.origin_price * to_convert_order.origin_quantity assert trailing_sell_orders[0] is replaced_sell_orders[0] assert trailing_sell_orders[1] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.03"), decimal.Decimal("28"), symbol, False ) # Test 3: to_convert_order with insufficient funds, trailing down to_convert_order_2 = trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.SELL_LIMIT, symbol, current_price, decimal.Decimal("0.1"), price=decimal.Decimal("30") ) # Mock insufficient funds scenario with mock.patch.object(trading_api, 'get_portfolio_currency') as mock_portfolio: mock_portfolio.return_value.available = decimal.Decimal("0.05") # Less than required 0.1 trailing_buy_orders_2, trailing_sell_orders_2 = producer._get_updated_trailing_orders( [], [], [], [], (to_convert_order_2, decimal.Decimal("28")), current_price, False ) # Should use available amount instead of ideal quantity assert len(trailing_buy_orders_2) == 1 assert len(trailing_sell_orders_2) == 0 assert trailing_buy_orders_2[0] == staggered_orders_trading.OrderData( trading_enums.TradeOrderSide.BUY, decimal.Decimal("0.001785714285714285714285714286"), decimal.Decimal("28"), symbol, False ) # Test 4: Regular cancelled order with trading_personal_data.Order regular_trading_order = trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.SELL_LIMIT, symbol, current_price, decimal.Decimal("0.08"), price=decimal.Decimal("32") ) trailing_buy_orders_4, trailing_sell_orders_4 = producer._get_updated_trailing_orders( [], [], [], [(regular_trading_order, decimal.Decimal("30"))], (to_convert_order, decimal.Decimal("22")), current_price, True ) assert trailing_buy_orders_4 == [] # Regular SELL order should keep same quantity assert len(trailing_sell_orders_4) == 2 assert trailing_sell_orders_4 == [ staggered_orders_trading.OrderData( # same as original trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.08"), decimal.Decimal("30"), symbol, False ), staggered_orders_trading.OrderData( # converted order trading_enums.TradeOrderSide.SELL, decimal.Decimal("0.0525"), decimal.Decimal("22"), symbol, False ), ] assert trailing_sell_orders_4[1].quantity * trailing_sell_orders_4[1].price == to_convert_order.origin_price * to_convert_order.origin_quantity async def test_get_orders_to_replace_with_updated_price_for_trailing_up(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.flat_increment = decimal.Decimal("5") producer.flat_spread = decimal.Decimal("10") current_price = decimal.Decimal("110.1") # will create buy orders at 100, 105 and a sell order at 115 side = trading_enums.TradeOrderSide.BUY quantity = decimal.Decimal("0.01") # 0. no input orders: should not happen, raises with pytest.raises(ValueError): producer._get_orders_to_replace_with_updated_price_for_trailing( [], [], current_price ) sorted_orders = [ trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.BUY_LIMIT, symbol, current_price, quantity, price=decimal.Decimal(str(price)) ) for price in range (10, 100, 5) # 10 to 95 ] # 1. no replaced orders, only existing orders, 4 orders to replace orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, [], current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[1], decimal.Decimal(str(100))), (sorted_orders[2], decimal.Decimal(str(105))), ] assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(115)) # 1. replaced orders and existing orders, same result: 4 orders to replace sorted_orders = [ trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.BUY_LIMIT, symbol, current_price, quantity, price=decimal.Decimal(str(price)) ) for price in range (10, 90, 5) # 10 to 85 ] replaced_orders = [ staggered_orders_trading.OrderData(side, quantity, decimal.Decimal(str(price)), symbol, False) for price in range(90, 100, 5) # 90 to 95 ] orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[1], decimal.Decimal(str(100))), (sorted_orders[2], decimal.Decimal(str(105))), ] assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(115)) # 2. replaced orders and existing orders, only 1 order to replace for current_price in [decimal.Decimal("95.1"), decimal.Decimal("100"), decimal.Decimal("104.9"), decimal.Decimal("105")]: orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(105)) # 3. replaced orders and existing orders, only 2 orders to replace for current_price in [decimal.Decimal("105.1"), decimal.Decimal("109.9"), decimal.Decimal("110")]: orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[1], decimal.Decimal(str(100))), ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(110)) # all sorted_orders to replace, but not replaced_orders current_price = decimal.Decimal("176") orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[i], decimal.Decimal(str(95 + i * 5))) for i in range(1, len(sorted_orders)) ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(180)) # exactly all sorted_orders to replace (price not going beyond) current_price = decimal.Decimal("191") orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ ((sorted_orders + replaced_orders)[i], decimal.Decimal(str(100 + i * 5))) for i in range(1, len(sorted_orders) + len(replaced_orders)) ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(195)) # all sorted_orders to replace and price is going way beyond current_price = decimal.Decimal("253.1") orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price[-1][1] == decimal.Decimal(str(245)) assert orders_to_replace_with_trailed_price == [ ((sorted_orders + replaced_orders)[i], decimal.Decimal(str(160 + i * 5))) # 160 to 245 for i in range(1, len(sorted_orders) + len(replaced_orders)) ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[0] assert other_side_order_price == decimal.Decimal(str(255)) async def test_get_orders_to_replace_with_updated_price_for_trailing_down(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.flat_increment = decimal.Decimal("5") producer.flat_spread = decimal.Decimal("10") current_price = decimal.Decimal("184.1") # will create sell orders at 95, 90 and a buy order at 80 side = trading_enums.TradeOrderSide.SELL quantity = decimal.Decimal("0.01") # 0. no input orders: should not happen, raises with pytest.raises(ValueError): producer._get_orders_to_replace_with_updated_price_for_trailing( [], [], current_price ) sorted_orders = [ trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.SELL_LIMIT, symbol, current_price, quantity, price=decimal.Decimal(str(price)) ) for price in range (200, 300, 5) # 200 to 295 ] # 1. no replaced orders, only existing orders, 4 orders to replace orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, [], current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[-2], decimal.Decimal(str(195))), (sorted_orders[-3], decimal.Decimal(str(190))), ] assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(180)) # 1. replaced orders and existing orders, same result: 4 orders to replace sorted_orders = [ trading_personal_data.create_order_instance( exchange_manager.trader, trading_enums.TraderOrderType.BUY_LIMIT, symbol, current_price, quantity, price=decimal.Decimal(str(price)) ) for price in range (210, 300, 5) # 210 to 295 ] replaced_orders = [ staggered_orders_trading.OrderData(side, quantity, decimal.Decimal(str(price)), symbol, False) for price in range(200, 210, 5) # 200 to 205 ] orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[-2], decimal.Decimal(str(195))), (sorted_orders[-3], decimal.Decimal(str(190))), ] assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(180)) # 2. replaced orders and existing orders, only 1 order to replace for current_price in [decimal.Decimal("199.9"), decimal.Decimal("194.9"), decimal.Decimal("195"), decimal.Decimal("190")]: orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(190)) # 3. replaced orders and existing orders, only 2 orders to replace for current_price in [decimal.Decimal("188.9"), decimal.Decimal("185.1"), decimal.Decimal("185")]: orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[-2], decimal.Decimal(str(195))), ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(185)) # all sorted_orders to replace, but not replaced_orders current_price = decimal.Decimal("107") orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ (sorted_orders[-i], decimal.Decimal(str(195 - (i - 2) * 5))) for i in range(2, len(sorted_orders) + 1) ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(105)) # exactly all sorted_orders to replace (price not going beyond) current_price = decimal.Decimal("96") orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price == [ ((replaced_orders + sorted_orders[:-1])[-i], decimal.Decimal(str(195 - (i - 1) * 5))) for i in range(1, len(sorted_orders) + len(replaced_orders)) ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(95)) # all sorted_orders to replace and price is going way beyond current_price = decimal.Decimal("42.122222222222") orders_to_replace_with_trailed_price, (order_to_replace_by_other_side_order, other_side_order_price) = producer._get_orders_to_replace_with_updated_price_for_trailing( sorted_orders, replaced_orders, current_price ) assert orders_to_replace_with_trailed_price[-1][1] == decimal.Decimal(str(50)) assert orders_to_replace_with_trailed_price == [ ((replaced_orders + sorted_orders[:-1])[-i], decimal.Decimal(str(140 - (i - 1) * 5))) # 140 to 50 for i in range(1, len(sorted_orders) + len(replaced_orders)) ] # only the other side order is set assert order_to_replace_by_other_side_order == sorted_orders[-1] assert other_side_order_price == decimal.Decimal(str(40)) async def test_prepare_full_grid_trailing(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools trading_api.force_set_mark_price(exchange_manager, symbol, 4000) with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \ as _ensure_current_price_in_limit_parameters_mock: await producer._ensure_staggered_orders() _ensure_current_price_in_limit_parameters_mock.assert_called_once() # price info: create orders assert producer.current_price == 4000 await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) # now has buy and sell orders open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() # simulate price being stable dependencies = trading_signals.get_orders_dependencies([mock.Mock(order_id="123")]) log_header = f"[{producer.exchange_manager.exchange_name}] {producer.symbol} @ {123} full grid trailing process: " with mock.patch.object( producer.trading_mode, "cancel_order", mock.AsyncMock(wraps=producer.trading_mode.cancel_order) ) as cancel_order_mock, mock.patch.object( octobot_trading.modes, "convert_asset_to_target_asset", mock.AsyncMock(wraps=octobot_trading.modes.convert_asset_to_target_asset) ) as convert_asset_to_target_asset_mock: cancelled_orders, created_orders, trailing_buy_orders, trailing_sell_orders, end_dependencies = await producer._prepare_full_grid_trailing( open_orders, 4000, dependencies, log_header ) assert trailing_buy_orders == trailing_sell_orders == [] assert end_dependencies == trading_signals.get_order_dependency(created_orders[0]) assert cancel_order_mock.call_count == len(open_orders) assert all( call.kwargs["dependencies"] is dependencies for call in cancel_order_mock.mock_calls ) cancelled_orders_dependencies = trading_signals.get_orders_dependencies( [call.args[0] for call in cancel_order_mock.mock_calls] ) assert convert_asset_to_target_asset_mock.call_count == 1 assert convert_asset_to_target_asset_mock.mock_calls[0].kwargs["dependencies"] == cancelled_orders_dependencies assert len(cancelled_orders) == len(open_orders) # cancelled orders updated_open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() assert updated_open_orders == [] # created order to balance BTC and USD (sell BTC) assert len(created_orders) == 1 assert created_orders[0].symbol == symbol assert created_orders[0].origin_quantity == decimal.Decimal("4.87500000") fees = created_orders[0].fee[trading_enums.FeePropertyColumns.COST.value] assert fees == decimal.Decimal("19.5") assert isinstance(created_orders[0], trading_personal_data.SellMarketOrder) portfolio = exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio # portfolio is now balanced assert portfolio["BTC"].available == decimal.Decimal("5.125") # 5.125 x 4000 = 20500 assert portfolio["USD"].available == decimal.Decimal("20480.5") == decimal.Decimal("20500") - fees # price change (going down), no order to cancel: just adapt pf trading_api.force_set_mark_price(exchange_manager, symbol, 3000) cancelled_orders, created_orders, trailing_buy_orders, trailing_sell_orders, dependencies = await producer._prepare_full_grid_trailing(open_orders, 3000, dependencies, log_header) assert trailing_buy_orders == trailing_sell_orders == [] assert dependencies == trading_signals.get_order_dependency(created_orders[0]) # no order to cancel (orders are already cancelled) assert len(cancelled_orders) == 0 # created order to balance BTC and USD (buy BTC) assert len(created_orders) == 1 assert created_orders[0].symbol == symbol assert created_orders[0].origin_quantity == decimal.Decimal('0.85091666') fees = created_orders[0].fee[trading_enums.FeePropertyColumns.COST.value] assert fees == decimal.Decimal('0.00085091666') assert isinstance(created_orders[0], trading_personal_data.BuyMarketOrder) assert portfolio["BTC"].available == decimal.Decimal('5.97506574334') # Decimal('5.97506574334') x 3000 = Decimal('17925.19723002000') assert portfolio["USD"].available == decimal.Decimal('17927.75002000000') # price change (going up), no order to cancel: just adapt pf trading_api.force_set_mark_price(exchange_manager, symbol, 8000) cancelled_orders, created_orders, trailing_buy_orders, trailing_sell_orders, dependencies = await producer._prepare_full_grid_trailing([], 8000, dependencies, log_header) assert trailing_buy_orders == trailing_sell_orders == [] assert dependencies == trading_signals.get_order_dependency(created_orders[0]) # no order to cancel assert len(cancelled_orders) == 0 # created order to balance BTC and USD (buy BTC) assert len(created_orders) == 1 assert created_orders[0].symbol == symbol assert created_orders[0].origin_quantity == decimal.Decimal('1.86704849') fees = created_orders[0].fee[trading_enums.FeePropertyColumns.COST.value] assert fees == decimal.Decimal('14.93638792000') assert isinstance(created_orders[0], trading_personal_data.SellMarketOrder) assert portfolio["BTC"].available == decimal.Decimal('4.10801725334') # Decimal('4.10801725334') x 8000 = Decimal('32864.13802672000') assert portfolio["USD"].available == decimal.Decimal('32849.20155208000') async def test_should_trigger_trailing_not_all_buy_order_created(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: current_price = decimal.Decimal(4000) producer, _, exchange_manager = tools # A. no open order: no trailing assert producer._should_trigger_trailing([], current_price, False) is False producer.enable_trailing_up = producer.enable_trailing_down = True assert producer._should_trigger_trailing([], current_price, False) is False # create orders trading_api.force_set_mark_price(exchange_manager, symbol, 4000) with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \ as _ensure_current_price_in_limit_parameters_mock: await producer._ensure_staggered_orders() _ensure_current_price_in_limit_parameters_mock.assert_called_once() assert producer.current_price == 4000 await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) # now has buy and sell orders # B. trailing disabled producer.enable_trailing_up = producer.enable_trailing_down = False assert producer._should_trigger_trailing([], current_price, False) is False open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL] assert len(buy_orders) > 10 assert len(sell_orders) > 10 assert producer._should_trigger_trailing(buy_orders, current_price, False) is False assert producer._should_trigger_trailing(sell_orders, current_price, False) is False # C. trailing enabled producer.enable_trailing_up = True assert producer._should_trigger_trailing(sell_orders, current_price, False) is False # True because all buy orders couldn't be created: impossible to check accurately assert producer._should_trigger_trailing(buy_orders, current_price, False) is True producer.enable_trailing_down = True assert producer._should_trigger_trailing(sell_orders, current_price, False) is False assert producer._should_trigger_trailing(sell_orders, decimal.Decimal(100), False) is True assert producer._should_trigger_trailing(buy_orders, current_price, False) is True # D. no trailing if at least 1 order on each side assert producer._should_trigger_trailing(buy_orders + sell_orders, current_price, False) is False assert producer._should_trigger_trailing([buy_orders[0]] + sell_orders, current_price, False) is False # E. use open orders assert producer._should_trigger_trailing([], current_price, False) is False assert producer._should_trigger_trailing([], current_price, True) is False async def test_should_trigger_trailing_all_buy_order_created(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: current_price = decimal.Decimal(4000) producer, _, exchange_manager = tools producer.increment = decimal.Decimal("0.02") # instead of 0.04 # A. no open order: no trailing assert producer._should_trigger_trailing([], current_price, False) is False producer.enable_trailing_up = producer.enable_trailing_down = True assert producer._should_trigger_trailing([], current_price, False) is False # create orders trading_api.force_set_mark_price(exchange_manager, symbol, 4000) with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \ as _ensure_current_price_in_limit_parameters_mock: await producer._ensure_staggered_orders() _ensure_current_price_in_limit_parameters_mock.assert_called_once() assert producer.current_price == 4000 await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) # now has buy and sell orders # B. trailing disabled producer.enable_trailing_up = producer.enable_trailing_down = False assert producer._should_trigger_trailing([], current_price, False) is False open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL] assert len(buy_orders) > 10 assert len(sell_orders) > 10 assert producer._should_trigger_trailing(buy_orders, current_price, False) is False assert producer._should_trigger_trailing(sell_orders, current_price, False) is False # C. trailing enabled producer.enable_trailing_up = True assert producer._should_trigger_trailing(sell_orders, current_price, False) is False assert producer._should_trigger_trailing(sell_orders, None, False) is False assert producer._should_trigger_trailing(buy_orders, current_price, False) is False assert producer._should_trigger_trailing(buy_orders, decimal.Decimal(6000), False) is True assert producer._should_trigger_trailing(buy_orders, None, True) is True assert producer._should_trigger_trailing(buy_orders, None, False) is False producer.enable_trailing_down = True assert producer._should_trigger_trailing(sell_orders, current_price, False) is False assert producer._should_trigger_trailing(sell_orders, decimal.Decimal(2000), False) is True assert producer._should_trigger_trailing(sell_orders, None, True) is True assert producer._should_trigger_trailing(buy_orders, current_price, False) is False assert producer._should_trigger_trailing(buy_orders, None, False) is False assert producer._should_trigger_trailing(buy_orders, None, True) is True # D. no trailing if at least 1 order on each side assert producer._should_trigger_trailing(buy_orders + sell_orders, current_price, False) is False assert producer._should_trigger_trailing([buy_orders[0]] + sell_orders, current_price, False) is False # E. use open orders assert producer._should_trigger_trailing([], current_price, False) is False assert producer._should_trigger_trailing([], current_price, True) is False # has open orders on the other side async def test_order_notification_callback(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.increment = decimal.Decimal("0.02") # replaces 0.04 # create orders trading_api.force_set_mark_price(exchange_manager, symbol, 4000) with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \ as _ensure_current_price_in_limit_parameters_mock: await producer._ensure_staggered_orders() _ensure_current_price_in_limit_parameters_mock.assert_called_once() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) # cancel sell orders and change reference price to 6000: should trigger trailing open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL] for order in sell_orders: await exchange_manager.trader.cancel_order(order) trading_api.force_set_mark_price(exchange_manager, symbol, 6000) filled_order = buy_orders[0] filled_order.filled_price = 6000 producer.enable_trailing_up = producer.enable_trailing_down = False with mock.patch.object(producer, "_lock_portfolio_and_create_order_when_possible", mock.AsyncMock()) as _lock_portfolio_and_create_order_when_possible: await _fill_order(filled_order, exchange_manager, trigger_update_callback=True) # trailing disabled _lock_portfolio_and_create_order_when_possible.assert_called_once() assert len(exchange_manager.exchange_personal_data.orders_manager.get_open_orders()) == len(sell_orders) - 1 # will trail filled_order = buy_orders[1] filled_order.filled_price = 6000 producer.use_order_by_order_trailing = False producer.enable_trailing_up = producer.enable_trailing_down = True with mock.patch.object(producer, "_lock_portfolio_and_create_order_when_possible", mock.AsyncMock()) as _lock_portfolio_and_create_order_when_possible: await _fill_order(filled_order, exchange_manager, trigger_update_callback=True) # trailing trigger await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) updated_open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() buy_orders = [order for order in updated_open_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in updated_open_orders if order.side == trading_enums.TradeOrderSide.SELL] assert len(buy_orders) == 25 assert len(sell_orders) == 25 # trailed instead _lock_portfolio_and_create_order_when_possible.assert_not_called() filled_order = buy_orders[0] with mock.patch.object(producer, "_lock_portfolio_and_create_order_when_possible", mock.AsyncMock()) as _lock_portfolio_and_create_order_when_possible: await _fill_order(filled_order, exchange_manager, trigger_update_callback=True) # do not trail again, create mirror order instead _lock_portfolio_and_create_order_when_possible.assert_called_once() async def test_create_mirror_order_considering_exchange_fees(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.ignore_exchange_fees = False # create orders price = 100 producer.mode = staggered_orders_trading.StrategyModes.NEUTRAL trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL] buy_sell_increment = producer.flat_spread - producer.flat_increment # mirroring buy order buy_1 = buy_orders[0] assert buy_1.origin_price == decimal.Decimal("97") assert buy_1.origin_quantity == decimal.Decimal("0.46") assert buy_1.side == trading_enums.TradeOrderSide.BUY buy_1.filled_quantity = buy_1.origin_quantity # create_mirror order uses filled quantity buy_1_mirror_order = producer._create_mirror_order(buy_1.to_dict()) assert isinstance(buy_1_mirror_order, staggered_orders_trading.OrderData) assert buy_1_mirror_order.associated_entry_id == buy_1.order_id assert buy_1_mirror_order.side == trading_enums.TradeOrderSide.SELL assert buy_1_mirror_order.symbol == symbol assert buy_1_mirror_order.price == decimal.Decimal("99") == buy_1.origin_price + buy_sell_increment assert buy_1_mirror_order.quantity < buy_1.origin_quantity # adapted for exchange fees assert buy_1_mirror_order.quantity == decimal.Decimal('0.4595400') # mirroring sell order sell_1 = sell_orders[0] assert sell_1.origin_price == decimal.Decimal("103") assert sell_1.origin_quantity == decimal.Decimal('0.00464646') assert sell_1.side == trading_enums.TradeOrderSide.SELL sell_1.filled_quantity = sell_1.origin_quantity # create_mirror order uses filled quantity sell_1_mirror_order = producer._create_mirror_order(sell_1.to_dict()) assert isinstance(sell_1_mirror_order, staggered_orders_trading.OrderData) assert sell_1_mirror_order.associated_entry_id is None assert sell_1_mirror_order.side == trading_enums.TradeOrderSide.BUY assert sell_1_mirror_order.symbol == symbol assert sell_1_mirror_order.price == decimal.Decimal("101") == sell_1.origin_price - buy_sell_increment assert sell_1_mirror_order.quantity > sell_1.origin_quantity assert sell_1_mirror_order.quantity == decimal.Decimal('0.004733730639801980198019801981') # fill price is != from origin price => use origin price to avoid moving grid orders assert buy_1.origin_price == decimal.Decimal("97") buy_1.filled_price = decimal.Decimal("96") # simulate fill at 96 buy_2_mirror_order = producer._create_mirror_order(buy_1.to_dict()) assert isinstance(buy_2_mirror_order, staggered_orders_trading.OrderData) # mirror order price is still 99, even if fill price is not 97 assert buy_2_mirror_order.price == decimal.Decimal("99") == buy_1.origin_price + buy_sell_increment assert buy_2_mirror_order.associated_entry_id == buy_1.order_id assert buy_2_mirror_order.side == trading_enums.TradeOrderSide.SELL # new sell order quantity is equal to previous mirror order quantity: only the amount of USDT spend is smaller assert buy_2_mirror_order.quantity == buy_1_mirror_order.quantity assert buy_2_mirror_order.quantity == decimal.Decimal('0.4595400') # sell_1 will be found in trades assert sell_1.origin_price == decimal.Decimal("103") sell_1.filled_price = decimal.Decimal("110") # simulate fill at 110 await _fill_order(sell_1, exchange_manager, trigger_update_callback=False, producer=producer) maybe_trade, maybe_order = exchange_manager.exchange_personal_data.get_trade_or_open_order( sell_1.order_id ) assert maybe_trade assert maybe_trade.origin_price == decimal.Decimal("103") assert maybe_order is None sell_2_mirror_order = producer._create_mirror_order(sell_1.to_dict()) assert sell_2_mirror_order.associated_entry_id is None # mirror order price is still 101, even if fill price is not 110 assert sell_2_mirror_order.price == decimal.Decimal("101") == sell_1.origin_price - buy_sell_increment assert sell_2_mirror_order.side == trading_enums.TradeOrderSide.BUY # new buy order quantity is larger than previous one as sell order was filled at a higher price assert sell_2_mirror_order.quantity > sell_1_mirror_order.quantity assert sell_2_mirror_order.quantity == decimal.Decimal('0.005055854530099009900990099009') async def test_create_mirror_order_ignoring_exchange_fees(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools producer.ignore_exchange_fees = True # create orders price = 100 producer.mode = staggered_orders_trading.StrategyModes.NEUTRAL trading_api.force_set_mark_price(exchange_manager, producer.symbol, price) await producer._ensure_staggered_orders() await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth)) open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders() buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY] sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL] buy_sell_increment = producer.flat_spread - producer.flat_increment # mirroring buy order with enough remaining funds in portfolio to keep the same quantity buy_1 = buy_orders[0] assert buy_1.origin_price == decimal.Decimal("97") assert buy_1.origin_quantity == decimal.Decimal("0.46") assert buy_1.side == trading_enums.TradeOrderSide.BUY buy_1.filled_quantity = buy_1.origin_quantity # create_mirror_order uses filled quantity buy_1_mirror_order = producer._create_mirror_order(buy_1.to_dict()) assert isinstance(buy_1_mirror_order, staggered_orders_trading.OrderData) assert buy_1_mirror_order.associated_entry_id == buy_1.order_id assert buy_1_mirror_order.side == trading_enums.TradeOrderSide.SELL assert buy_1_mirror_order.symbol == symbol assert buy_1_mirror_order.price == decimal.Decimal("99") == buy_1.origin_price + buy_sell_increment assert buy_1_mirror_order.quantity == buy_1.origin_quantity # NOT adapted for exchange fees assert buy_1_mirror_order.quantity == decimal.Decimal('0.46') # mirroring buy order WITHOUT enough remaining funds in portfolio to keep the same quantity: # => sell order adapted to available funds buy_1 = buy_orders[0] assert buy_1.origin_price == decimal.Decimal("97") assert buy_1.origin_quantity == decimal.Decimal("0.46") assert buy_1.side == trading_enums.TradeOrderSide.BUY buy_1.filled_quantity = buy_1.origin_quantity # create_mirror_order uses filled quantity exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["BTC"].available = decimal.Decimal("0.3") buy_1_mirror_order = producer._create_mirror_order(buy_1.to_dict()) assert isinstance(buy_1_mirror_order, staggered_orders_trading.OrderData) assert buy_1_mirror_order.associated_entry_id == buy_1.order_id assert buy_1_mirror_order.side == trading_enums.TradeOrderSide.SELL assert buy_1_mirror_order.symbol == symbol assert buy_1_mirror_order.price == decimal.Decimal("99") == buy_1.origin_price + buy_sell_increment assert buy_1_mirror_order.quantity < buy_1.origin_quantity # adapted for available funds assert buy_1_mirror_order.quantity == decimal.Decimal('0.3') # equals to available funds # => buy order adapted to available funds sell_1 = sell_orders[0] assert sell_1.origin_price == decimal.Decimal("103") assert sell_1.origin_quantity == decimal.Decimal('0.00464646') # cost ~= 0.04 assert sell_1.side == trading_enums.TradeOrderSide.SELL sell_1.filled_quantity = sell_1.origin_quantity # create_mirror_order uses filled quantity exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio["USD"].available = decimal.Decimal("0.33") sell_1_mirror_order = producer._create_mirror_order(sell_1.to_dict()) assert isinstance(sell_1_mirror_order, staggered_orders_trading.OrderData) assert sell_1_mirror_order.associated_entry_id is None assert sell_1_mirror_order.side == trading_enums.TradeOrderSide.BUY assert sell_1_mirror_order.symbol == symbol assert sell_1_mirror_order.price == decimal.Decimal("101") == sell_1.origin_price - buy_sell_increment assert sell_1_mirror_order.quantity < sell_1.origin_quantity assert sell_1_mirror_order.quantity == decimal.Decimal('0.003267326732673267326732673267') # adapted to available USDT async def test_ensure_full_funds_usage(): symbol = "BTC/USD" async with _get_tools(symbol) as tools: producer, _, exchange_manager = tools orders = [] # no order, does not raise error assert not producer._ensure_full_funds_usage(orders, 0, 0) # funds are from partially filled orders, don't raise buy_order = trading_personal_data.BuyMarketOrder(exchange_manager.trader) buy_order.origin_quantity = decimal.Decimal(10) buy_order.origin_price = decimal.Decimal(100) buy_order.filled_quantity = buy_order.origin_quantity * decimal.Decimal("0.99") sell_order = trading_personal_data.SellMarketOrder(exchange_manager.trader) sell_order.origin_quantity = decimal.Decimal(10) sell_order.origin_price = decimal.Decimal(100) sell_order.filled_quantity = sell_order.origin_quantity * decimal.Decimal("0.99") orders = [buy_order, sell_order] assert not producer._ensure_full_funds_usage(orders, 1, 1) # raises buy_order.origin_quantity = decimal.Decimal(6) buy_order.filled_quantity = decimal.Decimal(0) sell_order.origin_quantity = decimal.Decimal(6) sell_order.filled_quantity = decimal.Decimal(0) with pytest.raises(staggered_orders_trading.ForceResetOrdersException): producer._ensure_full_funds_usage([buy_order], 1, 0) with pytest.raises(staggered_orders_trading.ForceResetOrdersException): producer._ensure_full_funds_usage([sell_order], 0, 1) with pytest.raises(staggered_orders_trading.ForceResetOrdersException): producer._ensure_full_funds_usage([buy_order, sell_order], 1, 1) # funds are now imbalanced: most of it is in buy orders base, quote = symbol_util.parse_symbol(producer.trading_mode.symbol).base_and_quote() quote_holdings = trading_api.get_portfolio_currency(exchange_manager, quote) base_holdings = trading_api.get_portfolio_currency(exchange_manager, base) quote_holdings.total = decimal.Decimal(1000) quote_holdings.available = decimal.Decimal(100) base_holdings.total = decimal.Decimal("0.2") base_holdings.available = decimal.Decimal("0.05") buy_order = trading_personal_data.BuyLimitOrder(exchange_manager.trader) buy_order.origin_quantity = decimal.Decimal(8) buy_order.origin_price = decimal.Decimal(100) # locks 800 USD sell_order = trading_personal_data.SellMarketOrder(exchange_manager.trader) sell_order.origin_quantity = decimal.Decimal("0.04") # locks 0.04 BTC, equivalent to 8 USD, < 0.05 available sell_order.origin_price = decimal.Decimal(200) # doesn't raise as buy orders are locking enough funds and sell order funds are too low to trigger a reset (avoid side effects when reaching the upper edge of the grid) assert not producer._ensure_full_funds_usage([buy_order, sell_order], 1, 1) # funds are now imbalanced: most of it is in sell orders quote_holdings.total = decimal.Decimal(80) quote_holdings.available = decimal.Decimal(40) base_holdings.total = decimal.Decimal("1") base_holdings.available = decimal.Decimal("0.1") buy_order.origin_quantity = decimal.Decimal(0.1) buy_order.origin_price = decimal.Decimal(100) # locks 10 USD buy_order_2 = trading_personal_data.BuyLimitOrder(exchange_manager.trader) buy_order_2.origin_quantity = decimal.Decimal(0.1) buy_order_2.origin_price = decimal.Decimal(100) # locks 10 USD sell_order.origin_quantity = decimal.Decimal(0.7) sell_order.origin_price = decimal.Decimal(100) # locks 70 USD # doesn't raise as sell orders are locking enough funds and buy order funds are too low to trigger a reset (avoid side effects when reaching the lower edge of the grid) assert not producer._ensure_full_funds_usage([buy_order, buy_order_2, sell_order], 2, 1) # now raises when funds are balanced buy_order_3 = trading_personal_data.BuyLimitOrder(exchange_manager.trader) buy_order_3.origin_quantity = decimal.Decimal(0.15) buy_order_3.origin_price = decimal.Decimal(100) # locks 15 USD with pytest.raises(staggered_orders_trading.ForceResetOrdersException): producer._ensure_full_funds_usage([buy_order, buy_order_2, buy_order_3, sell_order], 3, 1) async def _wait_for_orders_creation(orders_count=1): for _ in range(orders_count): await asyncio_tools.wait_asyncio_next_cycle() async def _check_open_orders_count(exchange_manager, count): await _wait_for_orders_creation(count) assert len(trading_api.get_open_orders(exchange_manager)) == count def _get_total_usd(exchange_manager, btc_price): return trading_api.get_portfolio_currency(exchange_manager, "USD", ).total \ + trading_api.get_portfolio_currency(exchange_manager, "BTC", ).total * btc_price async def _fill_order(order, exchange_manager, trigger_update_callback=True, producer=None): initial_len = len(trading_api.get_open_orders(exchange_manager)) await order.on_fill(force_fill=True) if order.status == trading_enums.OrderStatus.FILLED: assert len(trading_api.get_open_orders(exchange_manager)) == initial_len - 1 if trigger_update_callback: # Wait twice so allow `await asyncio_tools.wait_asyncio_next_cycle()` in order.initialize() to finish and complete # order creation AND roll the next cycle that will wake up any pending portfolio lock and allow it to # proceed (here `filled_order_state.terminate()` can be locked if an order has been previously filled AND # a mirror order is being created (and its `await asyncio_tools.wait_asyncio_next_cycle()` in order.initialize() # is pending: in this case `AbstractTradingModeConsumer.create_order_if_possible()` is still # locking the portfolio cause of the previous order's `await asyncio_tools.wait_asyncio_next_cycle()`)). # This lock issue can appear here because we don't use `asyncio_tools.wait_asyncio_next_cycle()` after mirror order # creation (unlike anywhere else in this test file). for _ in range(2): await asyncio_tools.wait_asyncio_next_cycle() else: with mock.patch.object(producer, "order_filled_callback", new=mock.AsyncMock()): await asyncio_tools.wait_asyncio_next_cycle() async def _test_mode(mode, expected_buy_count, expected_sell_count, price, lowest_buy=None, highest_sell=None, btc_holdings=None): symbol = "BTC/USD" async with _get_tools(symbol, btc_holdings=btc_holdings) as tools: producer, _, exchange_manager = tools if lowest_buy is not None: producer.lowest_buy = decimal.Decimal(str(lowest_buy)) if highest_sell is not None: producer.highest_sell = decimal.Decimal(str(highest_sell)) producer.mode = mode trading_api.force_set_mark_price(exchange_manager, symbol, price) _, _, _, _, symbol_market = await trading_personal_data.get_pre_order_data(exchange_manager, symbol=symbol, timeout=1) producer.symbol_market = symbol_market producer.current_price = price orders = await _check_generate_orders(exchange_manager, producer, expected_buy_count, expected_sell_count, price, symbol_market) await asyncio.create_task(_wait_for_orders_creation(len(orders))) open_orders = trading_api.get_open_orders(exchange_manager) if expected_buy_count or expected_sell_count: assert len(open_orders) <= producer.operational_depth _check_orders(open_orders, mode, producer, exchange_manager) assert trading_api.get_portfolio_currency(exchange_manager, "BTC").available >= trading_constants.ZERO assert trading_api.get_portfolio_currency(exchange_manager, "USD").available >= trading_constants.ZERO async def _check_generate_orders(exchange_manager, producer, expected_buy_count, expected_sell_count, price, symbol_market): async with exchange_manager.exchange_personal_data.portfolio_manager.portfolio.lock: producer._refresh_symbol_data(symbol_market) buy_orders, sell_orders, triggering_trailing, dependencies = await producer._generate_staggered_orders(decimal.Decimal(str(price)), False, False) assert dependencies is None assert len(buy_orders) == expected_buy_count assert len(sell_orders) == expected_sell_count assert triggering_trailing is False assert all(o.price < price for o in buy_orders) assert all(o.price > price for o in sell_orders) if buy_orders: assert not any(order for order in buy_orders if order.is_virtual) if sell_orders: assert any(order for order in sell_orders if order.is_virtual) buy_holdings = trading_api.get_portfolio_currency(exchange_manager, "USD").available assert sum(order.price * order.quantity for order in buy_orders) <= buy_holdings sell_holdings = trading_api.get_portfolio_currency(exchange_manager, "BTC").available assert sum(order.quantity for order in sell_orders) <= sell_holdings staggered_orders = producer._merged_and_sort_not_virtual_orders(buy_orders, sell_orders) if staggered_orders: assert not any(order for order in staggered_orders if order.is_virtual) await producer._create_not_virtual_orders(staggered_orders, price, triggering_trailing, dependencies) assert all(producer.highest_sell >= o.price >= producer.lowest_buy for o in sell_orders) assert all(producer.highest_sell >= o.price >= producer.lowest_buy for o in buy_orders) return staggered_orders def _check_orders(orders, strategy_mode, producer, exchange_manager): buy_increase_towards_center = staggered_orders_trading.StrategyModeMultipliersDetails[strategy_mode][ trading_enums.TradeOrderSide.BUY] == staggered_orders_trading.INCREASING sell_increase_towards_center = staggered_orders_trading.StrategyModeMultipliersDetails[strategy_mode][ trading_enums.TradeOrderSide.SELL] == staggered_orders_trading.INCREASING buy_flat_towards_center = staggered_orders_trading.StrategyModeMultipliersDetails[strategy_mode][ trading_enums.TradeOrderSide.SELL] == staggered_orders_trading.STABLE sell_flat_towards_center = staggered_orders_trading.StrategyModeMultipliersDetails[strategy_mode][ trading_enums.TradeOrderSide.SELL] == staggered_orders_trading.STABLE multiplier = staggered_orders_trading.StrategyModeMultipliersDetails[strategy_mode][ staggered_orders_trading.MULTIPLIER] first_buy = None first_sell = None current_buy = None current_sell = None in_sell_orders = True for order in orders: # first should be sell orders followed by buy orders if order.side is trading_enums.TradeOrderSide.BUY: in_sell_orders = False assert order.side is (trading_enums.TradeOrderSide.SELL if in_sell_orders else trading_enums.TradeOrderSide.BUY) if order.side == trading_enums.TradeOrderSide.BUY: if current_buy is None: current_buy = order first_buy = order else: # place buy orders from the lowest price up to the current price assert current_buy.origin_price > order.origin_price if buy_increase_towards_center: assert current_buy.origin_quantity * current_buy.origin_price > \ order.origin_quantity * order.origin_price elif buy_flat_towards_center: assert first_buy.origin_quantity * first_buy.origin_price * decimal.Decimal("0.99")\ <= current_buy.origin_quantity * current_buy.origin_price \ <= first_buy.origin_quantity * first_buy.origin_price * decimal.Decimal("1.01") else: assert current_buy.origin_quantity * current_buy.origin_price < \ order.origin_quantity * order.origin_price current_buy = order if order.side == trading_enums.TradeOrderSide.SELL: if current_sell is None: current_sell = order first_sell = order else: assert current_sell.origin_price < order.origin_price current_sell = order if sell_flat_towards_center: assert first_sell.origin_quantity * first_sell.origin_price * decimal.Decimal("0.99")\ <= current_sell.origin_quantity * current_sell.origin_price \ <= first_sell.origin_quantity * first_sell.origin_price * decimal.Decimal("1.01") order_limiting_currency_amount = trading_api.get_portfolio_currency(exchange_manager, "USD").total decimal_current_price = decimal.Decimal(str(producer.current_price)) _, average_order_quantity = \ producer._get_order_count_and_average_quantity(decimal_current_price, False, producer.lowest_buy, decimal_current_price, decimal.Decimal(str(order_limiting_currency_amount)), "USD", strategy_mode) if orders: if buy_increase_towards_center: assert round(multiplier * average_order_quantity * decimal_current_price) - 1 \ <= round(first_buy.origin_quantity * first_buy.origin_price - current_buy.origin_quantity * current_buy.origin_price) \ <= round(multiplier * average_order_quantity * decimal_current_price) + 1 else: assert round(multiplier * average_order_quantity * decimal_current_price) - 1 \ <= round(current_buy.origin_quantity * current_buy.origin_price - first_buy.origin_quantity * first_buy.origin_price) \ <= round(multiplier * average_order_quantity * decimal_current_price) + 1 order_limiting_currency_amount = trading_api.get_portfolio_currency(exchange_manager, "BTC").total _, average_order_quantity = \ producer._get_order_count_and_average_quantity(decimal_current_price, True, decimal_current_price, producer.highest_sell, decimal.Decimal(str(order_limiting_currency_amount)), "BTC", strategy_mode) if strategy_mode not in [staggered_orders_trading.StrategyModes.NEUTRAL, staggered_orders_trading.StrategyModes.VALLEY, staggered_orders_trading.StrategyModes.SELL_SLOPE]: # not exactly multiplier because of virtual orders and rounds if sell_increase_towards_center: expected_quantity = trading_personal_data.decimal_trunc_with_n_decimal_digits( average_order_quantity * (1 + multiplier / 2), 8) assert abs(first_sell.origin_quantity - expected_quantity) < \ multiplier * producer.increment / (2 * decimal_current_price) elif not sell_flat_towards_center: expected_quantity = trading_personal_data.decimal_trunc_with_n_decimal_digits( average_order_quantity * (1 - multiplier / 2), 8) assert abs(first_sell.origin_quantity - expected_quantity) < \ multiplier * producer.increment / (2 * decimal_current_price) async def _light_check_orders(producer, exchange_manager, expected_buy_count, expected_sell_count, price): buy_orders, sell_orders, triggering_trailing, dependencies = await producer._generate_staggered_orders(decimal.Decimal(str(price)), False, False) assert dependencies is None assert len(buy_orders) == expected_buy_count assert len(sell_orders) == expected_sell_count assert triggering_trailing is False assert all(o.price < price for o in buy_orders) assert all(o.price > price for o in sell_orders) buy_holdings = trading_api.get_portfolio_currency(exchange_manager, "ETH").available assert sum(order.price * order.quantity for order in buy_orders) <= buy_holdings sell_holdings = trading_api.get_portfolio_currency(exchange_manager, "RDN").available assert sum(order.quantity for order in sell_orders) <= sell_holdings staggered_orders = producer._merged_and_sort_not_virtual_orders(buy_orders, sell_orders) if staggered_orders: assert not any(order for order in staggered_orders if order.is_virtual) await producer._create_not_virtual_orders(staggered_orders, price, triggering_trailing, dependencies) await asyncio.create_task(_wait_for_orders_creation(len(staggered_orders))) open_orders = trading_api.get_open_orders(exchange_manager) if expected_buy_count or expected_sell_count: assert len(open_orders) <= producer.operational_depth trading_mode = producer.mode buy_increase_towards_center = staggered_orders_trading.StrategyModeMultipliersDetails[trading_mode][ trading_enums.TradeOrderSide.BUY] == staggered_orders_trading.INCREASING current_buy = None current_sell = None in_sell_orders = True for order in open_orders: # first should be sell orders followed by buy orders if order.side is trading_enums.TradeOrderSide.BUY: in_sell_orders = False assert order.side is (trading_enums.TradeOrderSide.SELL if in_sell_orders else trading_enums.TradeOrderSide.BUY) if order.side == trading_enums.TradeOrderSide.BUY: if current_buy is None: current_buy = order else: # place buy orders from the current price down to the lowest price assert current_buy.origin_price > order.origin_price if buy_increase_towards_center: assert current_buy.origin_quantity * current_buy.origin_price > \ order.origin_quantity * order.origin_price else: assert current_buy.origin_quantity * current_buy.origin_price < \ order.origin_quantity * order.origin_price current_buy = order if order.side == trading_enums.TradeOrderSide.SELL: if current_sell is None: current_sell = order else: assert current_sell.origin_price < order.origin_price current_sell = order assert trading_api.get_portfolio_currency(exchange_manager, "ETH").available >= 0 assert trading_api.get_portfolio_currency(exchange_manager, "RDN").available >= 0 def _get_multi_symbol_staggered_config(): return { "required_strategies": [], "pair_settings": [ { "pair": "BTC/USD", "mode": "mountain", "spread_percent": 4, "increment_percent": 3, "lower_bound": 4300, "upper_bound": 5500, "allow_instant_fill": True, "operational_depth": 100 }, { "pair": "ETH/USDT", "mode": "mountain", "spread_percent": 4, "increment_percent": 3, "lower_bound": 4300, "upper_bound": 5500, "allow_instant_fill": True, "operational_depth": 100 }, { "pair": "NANO/USDT", "mode": "mountain", "spread_percent": 4, "increment_percent": 3, "lower_bound": 4300, "upper_bound": 5500, "allow_instant_fill": True, "operational_depth": 100 } ] } ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/__init__.py ================================================ from .trading_view_signals_trading import TradingViewSignalsTradingMode ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/config/TradingViewSignalsTradingMode.json ================================================ { "close_to_current_price_difference": 0.02, "required_strategies": [], "use_maximum_size_orders": false, "use_market_orders": true } ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/metadata.json ================================================ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", "tentacles": ["TradingViewSignalsTradingMode"], "tentacles-requirements": ["trading_view_service_feed"] } ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/resources/TradingViewSignalsTradingMode.md ================================================ TradingViewSignalsTradingMode is a trading mode configured to automate orders creation on the exchange of your choice by following alerts from [TradingView](https://www.tradingview.com/?aff_id=27595) price events, indicators or strategies. Free TradingView email alerts as well as webhook alerts can be used to automate trades based on TradingView alerts.
To know more, checkout the full TradingView trading mode guide. ### Generate your own strategy using AI Describe your trading strategy to the OctoBot AI strategy generator and get your strategy as Pine Script in seconds. Automate it with your self-hosted OctoBot or a TradingView OctoBot.

Generate my strategy with AI

### Alert format cheatsheet Basic signals have the following format: ``` EXCHANGE=BINANCE SYMBOL=BTCUSD SIGNAL=BUY ``` Additional order details can be added to the signal but are optional: ``` ORDER_TYPE=LIMIT VOLUME=0.01 PRICE=42000 STOP_PRICE=25000 TAKE_PROFIT_PRICE=50000 REDUCE_ONLY=true ``` Where: - `ORDER_TYPE` is the type of order (LIMIT, MARKET or STOP). Overrides the `Use market orders` parameter - `VOLUME` is the volume of the order in base asset (BTC for BTC/USDT) it can a flat amount (ex: `0.1` to trade 0.1 BTC on BTC/USD), a % of the total portfolio value (ex: `2%`), a % of the available holdings (ex: `12a%`), a % of available holdings associated to the current traded symbol assets (`10s%`) or a % of available holdings associated to all configured trading pairs assets (`10t%`). It follows the orders amount syntax. - `PRICE` is the price of the limit order in quote asset (USDT for BTC/USDT). Can also be a delta value from the current price by adding `d` (ex: `10d` or `-0.55d`) or a delta percent from the price (ex: `-5%` or `25.4%`). It follows the orders price syntax. - `STOP_PRICE` is the price of the stop order to create. Can also be a delta or % delta like `PRICE`. When increasing the position or buying in spot trading, the stop loss will automatically be created once the initial order is filled. When decreasing the position (or selling in spot) using a LIMIT `ORDER_TYPE`, the stop loss will be created instantly. *Orders crated this way are compatible with PNL history.* It follows the orders price syntax. - `TAKE_PROFIT_PRICE` is the price of the take profit order to create. Can also be a delta or % delta like `PRICE`. When increasing the position or buying in spot trading, the take profit will automatically be created once the initial order is filled. When decreasing the position (or selling in spot) using a LIMIT `ORDER_TYPE`, the take profit will be created instantly. *Orders crated this way are compatible with PNL history.* It follows the orders price syntax. Funds will be evenly split between take profits unless a `TAKE_PROFIT_VOLUME_RATIO` is set for each take profit. Multiple take profit prices can be used from `TAKE_PROFIT_PRICE_1`, `TAKE_PROFIT_PRICE_2`, ... - `TAKE_PROFIT_VOLUME_RATIO` is the ratio of the entry order volume to include in this take profit. Used when multiple take profits are set. Specify multiple values using `TAKE_PROFIT_VOLUME_RATIO_1`, `TAKE_PROFIT_VOLUME_RATIO_2`, .... When used, a `TAKE_PROFIT_VOLUME_RATIO` is required for each take profit. Exemple: `TAKE_PROFIT_PRICE=1234;TAKE_PROFIT_PRICE_1=1456;TAKE_PROFIT_VOLUME_RATIO_1=1;TAKE_PROFIT_VOLUME_RATIO_2=2` will split 33% of entry amount in TP 1 and 67% in TP 2. - `REDUCE_ONLY` when true, only reduce the current position (avoid accidental short position opening when reducing a long position). **Only used in futures trading**. Default is false - `TAG` is an identifier to give to the orders to create. - `LEVERAGE` the leverage value to use when trading futures. When not specified, orders volume and price are automatically computed based on the current asset price and holdings. Orders can be cancelled using the following format: ``` bash EXCHANGE=binance SYMBOL=ETHBTC SIGNAL=CANCEL ``` Additional cancel parameters: - `PARAM_SIDE` is the side of the orders to cancel, it can be `buy` or `sell` to only cancel buy or sell orders. - `TAG` is the tag of the order(s) to cancel. It can be used to only cancel orders that have been created with a specific tag. Note: `;` can also be used to separate signal parameters, exemple: `EXCHANGE=binance;SYMBOL=ETHBTC;SIGNAL=CANCEL` is equivalent to the previous example. Find the full TradingView alerts format on the TradingView alerts format guide. ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/tests/__init__.py ================================================ ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/tests/test_trading_view_signals_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import math import mock import pytest import os.path import pytest_asyncio import async_channel.util as channel_util import octobot_backtesting.api as backtesting_api import octobot_commons.asyncio_tools as asyncio_tools import octobot_commons.constants as commons_constants import octobot_commons.symbols as commons_symbols import octobot_commons.tests.test_config as test_config import octobot_trading.constants as trading_constants import octobot_trading.api as trading_api import octobot_trading.exchange_channel as exchanges_channel import octobot_trading.enums as trading_enums import octobot_trading.exchanges as exchanges import octobot_trading.errors as errors import octobot_trading.personal_data as trading_personal_data import octobot_trading.signals as trading_signals import octobot_trading.modes.script_keywords as script_keywords import tentacles.Trading.Mode as Mode import tests.test_utils.config as test_utils_config import tests.test_utils.test_exchanges as test_exchanges import octobot_tentacles_manager.api as tentacles_manager_api # All test coroutines will be treated as marked. pytestmark = pytest.mark.asyncio @pytest_asyncio.fixture async def tools(): tentacles_manager_api.reload_tentacle_info() exchange_manager = None try: symbol = "BTC/USDT" config = test_config.load_test_config() config[commons_constants.CONFIG_SIMULATOR][commons_constants.CONFIG_STARTING_PORTFOLIO]["USDT"] = 2000 exchange_manager = test_exchanges.get_test_exchange_manager(config, "binance") exchange_manager.tentacles_setup_config = test_utils_config.get_tentacles_setup_config() # use backtesting not to spam exchanges apis exchange_manager.is_simulated = True exchange_manager.is_backtesting = True exchange_manager.use_cached_markets = False backtesting = await backtesting_api.initialize_backtesting( config, exchange_ids=[exchange_manager.id], matrix_id=None, data_files=[ os.path.join(test_config.TEST_CONFIG_FOLDER, "AbstractExchangeHistoryCollector_1586017993.616272.data") ]) exchange_manager.exchange = exchanges.ExchangeSimulator(exchange_manager.config, exchange_manager, backtesting) await exchange_manager.exchange.initialize() for exchange_channel_class_type in [exchanges_channel.ExchangeChannel, exchanges_channel.TimeFrameExchangeChannel]: await channel_util.create_all_subclasses_channel(exchange_channel_class_type, exchanges_channel.set_chan, exchange_manager=exchange_manager) trader = exchanges.TraderSimulator(config, exchange_manager) await trader.initialize() mode = Mode.TradingViewSignalsTradingMode(config, exchange_manager) mode.symbol = symbol await mode.initialize() # add mode to exchange manager so that it can be stopped and freed from memory exchange_manager.trading_modes.append(mode) producer = mode.producers[0] consumer = mode.get_trading_mode_consumers()[0] # set BTC/USDT price at 7009.194999999998 USDT last_btc_price = 7009.194999999998 trading_api.force_set_mark_price(exchange_manager, symbol, last_btc_price) exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder.portfolio_current_value = \ decimal.Decimal(str(last_btc_price * 10)) yield exchange_manager, symbol, mode, producer, consumer finally: if exchange_manager: try: await _stop(exchange_manager) except Exception as err: print(f"error when stopping exchange manager: {err}") async def _stop(exchange_manager): for importer in backtesting_api.get_importers(exchange_manager.exchange.backtesting): await backtesting_api.stop_importer(importer) await exchange_manager.exchange.backtesting.stop() await exchange_manager.stop() # let updaters gracefully shutdown await asyncio_tools.wait_asyncio_next_cycle() async def test_parse_signal_data(): errors = [] assert Mode.TradingViewSignalsTradingMode.parse_signal_data( """ KEY=value EXCHANGE=1 PLOp=true """, errors ) == { "KEY": "value", "EXCHANGE": "1", "PLOp": True, } assert errors == [] errors = [] assert Mode.TradingViewSignalsTradingMode.parse_signal_data( "KEY=value\nEXCHANGE=1\n\n\n\nPLOp=false\n", errors ) == { "KEY": "value", "EXCHANGE": "1", "PLOp": False, } assert errors == [] errors = [] assert Mode.TradingViewSignalsTradingMode.parse_signal_data( "KEY=value\\nEXCHANGE=1\\nPLOp=ABC", errors ) == { "KEY": "value", "EXCHANGE": "1", "PLOp": "ABC", } assert errors == [] errors = [] assert Mode.TradingViewSignalsTradingMode.parse_signal_data( "KEY=value\\nEXCHANGE\\nPLOp=ABC", errors ) == { "KEY": "value", "PLOp": "ABC", } assert len(errors) == 1 assert "EXCHANGE" in str(errors[0]) assert "nPLOp" not in str(errors[0]) assert "KEY" not in str(errors[0]) errors = [] assert Mode.TradingViewSignalsTradingMode.parse_signal_data( "KEY=value;EXCHANGE;;;;;PLOp=ABC;TAKE_PROFIT_PRICE=1;;TAKE_PROFIT_PRICE_2=3", errors ) == { "KEY": "value", "PLOp": "ABC", "TAKE_PROFIT_PRICE": "1", "TAKE_PROFIT_PRICE_2": "3", } assert len(errors) == 1 assert "EXCHANGE" in str(errors[0]) assert "nPLOp" not in str(errors[0]) assert "KEY" not in str(errors[0]) errors = [] assert Mode.TradingViewSignalsTradingMode.parse_signal_data( ";KEY=value;EXCHANGE\nPLOp=ABC\\nGG=HIHI;LEVERAGE=3", errors ) == { "KEY": "value", "PLOp": "ABC", "GG": "HIHI", "LEVERAGE": "3", } assert len(errors) == 1 assert "EXCHANGE" in str(errors[0]) assert "nPLOp" not in str(errors[0]) assert "KEY" not in str(errors[0]) assert "LEVERAGE" not in str(errors[0]) async def test_trading_view_signal_callback(tools): exchange_manager, symbol, mode, producer, consumer = tools context = script_keywords.get_base_context(producer.trading_mode) with mock.patch.object(script_keywords, "get_base_context", mock.Mock(return_value=context)) \ as get_base_context_mock: for exception in (errors.MissingFunds, errors.InvalidArgumentError): # ensure exception is caught with mock.patch.object( producer, "signal_callback", mock.AsyncMock(side_effect=exception) ) as signal_callback_mock: signal = f""" EXCHANGE={exchange_manager.exchange_name} SYMBOL={symbol} SIGNAL=BUY """ await mode._trading_view_signal_callback({"metadata": signal}) signal_callback_mock.assert_awaited_once() get_base_context_mock.assert_called_once() get_base_context_mock.reset_mock() with mock.patch.object(producer, "signal_callback", mock.AsyncMock()) as signal_callback_mock: # invalid data data = "" await mode._trading_view_signal_callback({"metadata": data}) signal_callback_mock.assert_not_awaited() signal_callback_mock.reset_mock() get_base_context_mock.assert_not_called() # invalid symbol data = f""" EXCHANGE={exchange_manager.exchange_name} SYMBOL={symbol}PLOP SIGNAL=BUY """ await mode._trading_view_signal_callback({"metadata": data}) signal_callback_mock.assert_not_awaited() signal_callback_mock.reset_mock() get_base_context_mock.assert_not_called() # minimal signal data = f""" EXCHANGE={exchange_manager.exchange_name} SYMBOL={symbol} SIGNAL=BUY """ await mode._trading_view_signal_callback({"metadata": data}) signal_callback_mock.assert_awaited_once_with({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: symbol, mode.SIGNAL_KEY: "BUY", }, context) signal_callback_mock.reset_mock() get_base_context_mock.assert_called_once() get_base_context_mock.reset_mock() # minimal signal signal = f""" EXCHANGE={exchange_manager.exchange_name} SYMBOL={symbol} SIGNAL=BUY """ await mode._trading_view_signal_callback({"metadata": signal}) signal_callback_mock.assert_awaited_once_with({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: symbol, mode.SIGNAL_KEY: "BUY", }, context) signal_callback_mock.reset_mock() get_base_context_mock.assert_called_once() get_base_context_mock.reset_mock() # other signals signal = f""" EXCHANGE={exchange_manager.exchange_name} SYMBOL={commons_symbols.parse_symbol(symbol).merged_str_base_and_quote_only_symbol( market_separator="" )} SIGNAL=BUY HEELLO=True PLOP=faLse """ await mode._trading_view_signal_callback({"metadata": signal}) signal_callback_mock.assert_awaited_once_with({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: commons_symbols.parse_symbol(symbol).merged_str_base_and_quote_only_symbol( market_separator="" ), mode.SIGNAL_KEY: "BUY", "HEELLO": True, "PLOP": False, }, context) signal_callback_mock.reset_mock() get_base_context_mock.assert_called_once() get_base_context_mock.reset_mock() async def test_signal_callback(tools): exchange_manager, symbol, mode, producer, consumer = tools context = script_keywords.get_base_context(producer.trading_mode) with mock.patch.object(producer, "_set_state", mock.AsyncMock()) as _set_state_mock, \ mock.patch.object(mode, "set_leverage", mock.AsyncMock()) as set_leverage_mock: await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "BUY", }, context) _set_state_mock.assert_awaited_once() set_leverage_mock.assert_not_called() assert _set_state_mock.await_args[0][1] == symbol assert _set_state_mock.await_args[0][2] == trading_enums.EvaluatorStates.VERY_LONG assert compare_dict_with_nan(_set_state_mock.await_args[0][3], { consumer.PRICE_KEY: trading_constants.ZERO, consumer.VOLUME_KEY: trading_constants.ZERO, consumer.STOP_PRICE_KEY: decimal.Decimal(math.nan), consumer.STOP_ONLY: False, consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal(math.nan), consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: [], consumer.REDUCE_ONLY_KEY: False, consumer.TAG_KEY: None, consumer.TRAILING_PROFILE: None, consumer.EXCHANGE_ORDER_IDS: None, consumer.LEVERAGE: None, consumer.ORDER_EXCHANGE_CREATION_PARAMS: {}, consumer.CANCEL_POLICY: None, consumer.CANCEL_POLICY_PARAMS: None, }) _set_state_mock.reset_mock() await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "SELL", mode.ORDER_TYPE_SIGNAL: "stop", mode.STOP_PRICE_KEY: 25000, mode.VOLUME_KEY: "12%", mode.TAG_KEY: "stop_1_tag", mode.CANCEL_POLICY: trading_personal_data.ExpirationTimeOrderCancelPolicy.__name__, mode.CANCEL_POLICY_PARAMS: { "expiration_time": 1000.0, }, consumer.EXCHANGE_ORDER_IDS: None, }, context) set_leverage_mock.assert_not_called() _set_state_mock.assert_awaited_once() assert _set_state_mock.await_args[0][1] == symbol assert _set_state_mock.await_args[0][2] == trading_enums.EvaluatorStates.SHORT assert compare_dict_with_nan(_set_state_mock.await_args[0][3], { consumer.PRICE_KEY: trading_constants.ZERO, consumer.VOLUME_KEY: decimal.Decimal("1.2"), consumer.STOP_PRICE_KEY: decimal.Decimal("25000"), consumer.STOP_ONLY: True, consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal(math.nan), consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: [], consumer.REDUCE_ONLY_KEY: False, consumer.TAG_KEY: "stop_1_tag", consumer.EXCHANGE_ORDER_IDS: None, consumer.TRAILING_PROFILE: None, consumer.LEVERAGE: None, consumer.ORDER_EXCHANGE_CREATION_PARAMS: {}, consumer.CANCEL_POLICY: trading_personal_data.ExpirationTimeOrderCancelPolicy.__name__, consumer.CANCEL_POLICY_PARAMS: {'expiration_time': 1000.0}, }) _set_state_mock.reset_mock() await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "SelL", mode.PRICE_KEY: "123", mode.VOLUME_KEY: "12%", mode.REDUCE_ONLY_KEY: True, mode.ORDER_TYPE_SIGNAL: "LiMiT", mode.STOP_PRICE_KEY: "12", mode.TAKE_PROFIT_PRICE_KEY: "22222", mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], mode.CANCEL_POLICY: "chainedorderfillingpriceordercancelpolicy", consumer.LEVERAGE: 22, "PARAM_TAG_1": "ttt", "PARAM_Plop": False, }, context) set_leverage_mock.assert_called_once() assert set_leverage_mock.mock_calls[0].args[2] == decimal.Decimal(22) set_leverage_mock.reset_mock() _set_state_mock.assert_awaited_once() assert _set_state_mock.await_args[0][1] == symbol assert _set_state_mock.await_args[0][2] == trading_enums.EvaluatorStates.SHORT assert compare_dict_with_nan(_set_state_mock.await_args[0][3], { consumer.PRICE_KEY: decimal.Decimal("123"), consumer.VOLUME_KEY: decimal.Decimal("1.2"), consumer.STOP_PRICE_KEY: decimal.Decimal("12"), consumer.STOP_ONLY: False, consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("22222"), consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: [], consumer.REDUCE_ONLY_KEY: True, consumer.TAG_KEY: None, mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], consumer.TRAILING_PROFILE: None, consumer.LEVERAGE: 22, consumer.ORDER_EXCHANGE_CREATION_PARAMS: { "TAG_1": "ttt", "Plop": False, }, consumer.CANCEL_POLICY: trading_personal_data.ChainedOrderFillingPriceOrderCancelPolicy.__name__, consumer.CANCEL_POLICY_PARAMS: None, }) _set_state_mock.reset_mock() # with trailing profile and TP volume await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "SelL", mode.PRICE_KEY: "123", mode.VOLUME_KEY: "12%", mode.REDUCE_ONLY_KEY: True, mode.ORDER_TYPE_SIGNAL: "LiMiT", mode.STOP_PRICE_KEY: "12", mode.TAKE_PROFIT_PRICE_KEY: "22222", mode.TAKE_PROFIT_VOLUME_RATIO_KEY: "1", mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], mode.TRAILING_PROFILE: "fiLLED_take_profit", mode.CANCEL_POLICY: "expirationtimeordercancelpolicy", mode.CANCEL_POLICY_PARAMS: "{'expiration_time': 1000.0}", consumer.LEVERAGE: 22, "PARAM_TAG_1": "ttt", "PARAM_Plop": False, }, context) set_leverage_mock.assert_called_once() assert set_leverage_mock.mock_calls[0].args[2] == decimal.Decimal(22) set_leverage_mock.reset_mock() _set_state_mock.assert_awaited_once() assert _set_state_mock.await_args[0][1] == symbol assert _set_state_mock.await_args[0][2] == trading_enums.EvaluatorStates.SHORT assert compare_dict_with_nan(_set_state_mock.await_args[0][3], { consumer.PRICE_KEY: decimal.Decimal("123"), consumer.VOLUME_KEY: decimal.Decimal("1.2"), consumer.STOP_PRICE_KEY: decimal.Decimal("12"), consumer.STOP_ONLY: False, consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("22222"), consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: [decimal.Decimal(1)], consumer.REDUCE_ONLY_KEY: True, consumer.TAG_KEY: None, mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], consumer.LEVERAGE: 22, consumer.TRAILING_PROFILE: "filled_take_profit", consumer.ORDER_EXCHANGE_CREATION_PARAMS: { "TAG_1": "ttt", "Plop": False, }, consumer.CANCEL_POLICY: trading_personal_data.ExpirationTimeOrderCancelPolicy.__name__, consumer.CANCEL_POLICY_PARAMS: {'expiration_time': 1000.0}, }) _set_state_mock.reset_mock() # future exchange: call set_leverage exchange_manager.is_future = True trading_api.load_pair_contract( exchange_manager, trading_api.create_default_future_contract( "BTC/USDT", decimal.Decimal(4), trading_enums.FutureContractType.LINEAR_PERPETUAL, trading_constants.DEFAULT_SYMBOL_POSITION_MODE ).to_dict() ) await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "SelL", mode.PRICE_KEY: "123@", # price = 123 mode.VOLUME_KEY: "100q", # base amount mode.REDUCE_ONLY_KEY: False, mode.ORDER_TYPE_SIGNAL: "LiMiT", mode.STOP_PRICE_KEY: "-10%", # price - 10% f"{mode.TAKE_PROFIT_PRICE_KEY}_0": "120.333333333333333d", # price + 120.333333333333333 f"{mode.TAKE_PROFIT_PRICE_KEY}_1": "122.333333333333333d", # price + 122.333333333333333 f"{mode.TAKE_PROFIT_PRICE_KEY}_2": "4444d", # price + 4444 f"{mode.TAKE_PROFIT_VOLUME_RATIO_KEY}_0": "1", f"{mode.TAKE_PROFIT_VOLUME_RATIO_KEY}_1": "1.122", f"{mode.TAKE_PROFIT_VOLUME_RATIO_KEY}_2": "0.2222", mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], consumer.LEVERAGE: 22, "PARAM_TAG_1": "ttt", "PARAM_Plop": False, }, context) set_leverage_mock.assert_called_once() assert set_leverage_mock.mock_calls[0].args[2] == decimal.Decimal("22") _set_state_mock.assert_awaited_once() assert _set_state_mock.await_args[0][1] == symbol assert _set_state_mock.await_args[0][2] == trading_enums.EvaluatorStates.SHORT assert compare_dict_with_nan(_set_state_mock.await_args[0][3], { consumer.PRICE_KEY: decimal.Decimal("123"), consumer.VOLUME_KEY: decimal.Decimal("0.8130081300813008130081300813"), consumer.STOP_PRICE_KEY: decimal.Decimal("6308.27549999"), consumer.STOP_ONLY: False, consumer.TAKE_PROFIT_PRICE_KEY: decimal.Decimal("nan"), # only additional TP orders are provided consumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: [ decimal.Decimal("7129.52833333"), decimal.Decimal("7131.52833333"), decimal.Decimal('11453.19499999') ], consumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: [ decimal.Decimal("1"), decimal.Decimal("1.122"), decimal.Decimal("0.2222"), ], consumer.REDUCE_ONLY_KEY: False, consumer.TAG_KEY: None, mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], consumer.TRAILING_PROFILE: None, consumer.LEVERAGE: 22, consumer.ORDER_EXCHANGE_CREATION_PARAMS: { "TAG_1": "ttt", "Plop": False, }, consumer.CANCEL_POLICY: None, consumer.CANCEL_POLICY_PARAMS: None, }) _set_state_mock.reset_mock() set_leverage_mock.reset_mock() with pytest.raises(errors.MissingFunds): await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "SelL", mode.PRICE_KEY: "123000q", # price = 123 mode.VOLUME_KEY: "11111b", # base amount: not enough funds mode.REDUCE_ONLY_KEY: True, mode.ORDER_TYPE_SIGNAL: "LiMiT", mode.STOP_PRICE_KEY: "-10%", # price - 10% mode.TAKE_PROFIT_PRICE_KEY: "120.333333333333333d", # price + 120.333333333333333 mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], mode.LEVERAGE: None, "PARAM_TAG_1": "ttt", "PARAM_Plop": False, }, context) set_leverage_mock.assert_not_called() _set_state_mock.assert_not_called() with pytest.raises(errors.InvalidArgumentError): await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "DSDSDDSS", mode.PRICE_KEY: "123000q", # price = 123 mode.VOLUME_KEY: "11111b", # base amount: not enough funds mode.REDUCE_ONLY_KEY: True, mode.ORDER_TYPE_SIGNAL: "LiMiT", mode.STOP_PRICE_KEY: "-10%", # price - 10% mode.TAKE_PROFIT_PRICE_KEY: "120.333333333333333d", # price + 120.333333333333333 mode.EXCHANGE_ORDER_IDS: ["ab1", "aaaaa"], mode.LEVERAGE: None, "PARAM_TAG_1": "ttt", "PARAM_Plop": False, }, context) set_leverage_mock.assert_not_called() _set_state_mock.assert_not_called() with pytest.raises(errors.InvalidCancelPolicyError): await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "SelL", mode.CANCEL_POLICY: "unknown_cancel_policy", }, context) set_leverage_mock.assert_not_called() _set_state_mock.assert_not_called() async def test_signal_callback_with_cancel_policies(tools): exchange_manager, symbol, mode, producer, consumer = tools context = script_keywords.get_base_context(producer.trading_mode) mode.CANCEL_PREVIOUS_ORDERS = True async def _apply_cancel_policies(*args, **kwargs): return True, trading_signals.get_orders_dependencies([mock.Mock(order_id="123"), mock.Mock(order_id="456-cancel_policy")]) async def _cancel_symbol_open_orders(*args, **kwargs): return True, trading_signals.get_orders_dependencies([mock.Mock(order_id="456-cancel_symbol_open_orders")]) with mock.patch.object(producer, "_set_state", mock.AsyncMock()) as _set_state_mock, \ mock.patch.object(producer, "_process_pre_state_update_actions", mock.AsyncMock()) as _process_pre_state_update_actions_mock, \ mock.patch.object(producer, "_parse_order_details", mock.AsyncMock(return_value=(trading_enums.EvaluatorStates.LONG, {}))) as _parse_order_details_mock, \ mock.patch.object(producer, "apply_cancel_policies", mock.AsyncMock(side_effect=_apply_cancel_policies)) as apply_cancel_policies_mock, \ mock.patch.object(producer, "cancel_symbol_open_orders", mock.AsyncMock(side_effect=_cancel_symbol_open_orders)) as cancel_symbol_open_orders_mock: await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "BUY", }, context) _process_pre_state_update_actions_mock.assert_awaited_once() _parse_order_details_mock.assert_awaited_once() apply_cancel_policies_mock.assert_awaited_once() cancel_symbol_open_orders_mock.assert_awaited_once() _set_state_mock.assert_awaited_once() assert _set_state_mock.mock_calls[0].kwargs["dependencies"] == trading_signals.get_orders_dependencies([ mock.Mock(order_id="123"), mock.Mock(order_id="456-cancel_policy"), mock.Mock(order_id="456-cancel_symbol_open_orders") ]) mode.CANCEL_PREVIOUS_ORDERS = False with mock.patch.object(producer, "_set_state", mock.AsyncMock()) as _set_state_mock, \ mock.patch.object(producer, "_process_pre_state_update_actions", mock.AsyncMock()) as _process_pre_state_update_actions_mock, \ mock.patch.object(producer, "_parse_order_details", mock.AsyncMock(return_value=(trading_enums.EvaluatorStates.LONG, {}))) as _parse_order_details_mock, \ mock.patch.object(producer, "apply_cancel_policies", mock.AsyncMock(side_effect=_apply_cancel_policies)) as apply_cancel_policies_mock, \ mock.patch.object(producer, "cancel_symbol_open_orders", mock.AsyncMock(side_effect=_cancel_symbol_open_orders)) as cancel_symbol_open_orders_mock: await producer.signal_callback({ mode.EXCHANGE_KEY: exchange_manager.exchange_name, mode.SYMBOL_KEY: "unused", mode.SIGNAL_KEY: "BUY", }, context) _process_pre_state_update_actions_mock.assert_awaited_once() _parse_order_details_mock.assert_awaited_once() apply_cancel_policies_mock.assert_awaited_once() cancel_symbol_open_orders_mock.assert_not_called() # CANCEL_PREVIOUS_ORDERS is False _set_state_mock.assert_awaited_once() assert _set_state_mock.mock_calls[0].kwargs["dependencies"] == trading_signals.get_orders_dependencies([ mock.Mock(order_id="123"), mock.Mock(order_id="456-cancel_policy"), ]) def compare_dict_with_nan(d_1, d_2): try: for key, val in d_1.items(): assert ( d_2[key] == d_1[key] or (isinstance(d_2[key], decimal.Decimal) and d_2[key].is_nan() and isinstance(d_1[key], decimal.Decimal) and d_1[key].is_nan()) or compare_dict_with_nan(d_1[key], d_2[key]) ), f"Key {key} is not equal: {d_1[key]} != {d_2[key]}" return True except (KeyError, AttributeError) as err: # print(f"Error comparing dicts: {err.__class__.__name__}: {err}") return False ================================================ FILE: Trading/Mode/trading_view_signals_trading_mode/trading_view_signals_trading.py ================================================ # Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 3.0 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. import decimal import math import typing import json import copy import async_channel.channels as channels import octobot_commons.symbols.symbol_util as symbol_util import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants import octobot_commons.signals as commons_signals import octobot_commons.tentacles_management as tentacles_management import octobot_services.api as services_api import octobot_trading.personal_data as trading_personal_data try: import tentacles.Services.Services_feeds.trading_view_service_feed as trading_view_service_feed except ImportError: if commons_constants.USE_MINIMAL_LIBS: # mock trading_view_service_feed imports class TradingViewServiceFeedImportMock: class TradingViewServiceFeed: def get_name(self, *args, **kwargs): raise ImportError("trading_view_service_feed not installed") trading_view_service_feed = TradingViewServiceFeedImportMock() import tentacles.Trading.Mode.daily_trading_mode.daily_trading as daily_trading_mode import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.exchanges as trading_exchanges import octobot_trading.modes as trading_modes import octobot_trading.errors as trading_errors import octobot_trading.modes.script_keywords as script_keywords _CANCEL_POLICIES_CACHE = {} class TradingViewSignalsTradingMode(trading_modes.AbstractTradingMode): SERVICE_FEED_CLASS = trading_view_service_feed.TradingViewServiceFeed if hasattr(trading_view_service_feed, 'TradingViewServiceFeed') else None TRADINGVIEW_FUTURES_SUFFIXES = [".P"] PARAM_SEPARATORS = [";", "\\n", "\n"] EXCHANGE_KEY = "EXCHANGE" TRADING_TYPE_KEY = "TRADING_TYPE" # expect a trading_enums.ExchangeTypes value SYMBOL_KEY = "SYMBOL" SIGNAL_KEY = "SIGNAL" PRICE_KEY = "PRICE" VOLUME_KEY = "VOLUME" REDUCE_ONLY_KEY = "REDUCE_ONLY" ORDER_TYPE_SIGNAL = "ORDER_TYPE" STOP_PRICE_KEY = "STOP_PRICE" TAG_KEY = "TAG" EXCHANGE_ORDER_IDS = "EXCHANGE_ORDER_IDS" LEVERAGE = "LEVERAGE" TAKE_PROFIT_PRICE_KEY = "TAKE_PROFIT_PRICE" TAKE_PROFIT_VOLUME_RATIO_KEY = "TAKE_PROFIT_VOLUME_RATIO" ALLOW_HOLDINGS_ADAPTATION_KEY = "ALLOW_HOLDINGS_ADAPTATION" TRAILING_PROFILE = "TRAILING_PROFILE" CANCEL_POLICY = "CANCEL_POLICY" CANCEL_POLICY_PARAMS = "CANCEL_POLICY_PARAMS" PARAM_PREFIX_KEY = "PARAM_" BUY_SIGNAL = "buy" SELL_SIGNAL = "sell" MARKET_SIGNAL = "market" LIMIT_SIGNAL = "limit" STOP_SIGNAL = "stop" CANCEL_SIGNAL = "cancel" SIDE_PARAM_KEY = "SIDE" def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.USE_MARKET_ORDERS = True self.CANCEL_PREVIOUS_ORDERS = True self.merged_simple_symbol = None self.str_symbol = None def init_user_inputs(self, inputs: dict) -> None: """ Called right before starting the tentacle, should define all the tentacle's user inputs unless those are defined somewhere else. """ self.UI.user_input( "use_maximum_size_orders", commons_enums.UserInputTypes.BOOLEAN, False, inputs, title="All in trades: Trade with all available funds at each order.", ) self.USE_MARKET_ORDERS = self.UI.user_input( "use_market_orders", commons_enums.UserInputTypes.BOOLEAN, True, inputs, title="Use market orders: If enabled, placed orders will be market orders only. Otherwise order prices " "are set using the Fixed limit prices difference value.", ) self.UI.user_input( "close_to_current_price_difference", commons_enums.UserInputTypes.FLOAT, 0.005, inputs, min_val=0, title="Fixed limit prices difference: Difference to take into account when placing a limit order " "(used if fixed limit prices is enabled). For a 200 USD price and 0.005 in difference: " "buy price would be 199 and sell price 201.", ) self.CANCEL_PREVIOUS_ORDERS = self.UI.user_input( "cancel_previous_orders", commons_enums.UserInputTypes.BOOLEAN, True, inputs, title="Cancel previous orders: If enabled, cancel other orders associated to the same symbol when " "receiving a signal. This way, only the latest signal will be taken into account.", ) @classmethod def get_supported_exchange_types(cls) -> list: """ :return: The list of supported exchange types """ return [ trading_enums.ExchangeTypes.SPOT, trading_enums.ExchangeTypes.FUTURE, ] def get_current_state(self) -> (str, float): return super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, \ self.producers[0].final_eval def get_mode_producer_classes(self) -> list: return [TradingViewSignalsModeProducer] def get_mode_consumer_classes(self) -> list: return [TradingViewSignalsModeConsumer] async def _get_feed_consumers(self): parsed_symbol = symbol_util.parse_symbol(self.symbol) self.str_symbol = str(parsed_symbol) self.merged_simple_symbol = parsed_symbol.merged_str_base_and_quote_only_symbol(market_separator="") feed_consumer = [] if self.SERVICE_FEED_CLASS is None: if commons_constants.USE_MINIMAL_LIBS: self.logger.debug( "Trading view service feed not installed, this trading mode won't be listening to trading view signals." ) else: raise ImportError("TradingViewServiceFeed not installed") else: service_feed = services_api.get_service_feed(self.SERVICE_FEED_CLASS, self.bot_id) if service_feed is not None: feed_consumer = [await channels.get_chan(service_feed.FEED_CHANNEL.get_name()).new_consumer( self._trading_view_signal_callback )] else: self.logger.error("Impossible to find the Trading view service feed, this trading mode can't work.") return feed_consumer async def create_consumers(self) -> list: consumers = await super().create_consumers() return consumers + await self._get_feed_consumers() @classmethod def _adapt_symbol(cls, parsed_data): if cls.SYMBOL_KEY not in parsed_data: return symbol = parsed_data[cls.SYMBOL_KEY] for suffix in cls.TRADINGVIEW_FUTURES_SUFFIXES: if symbol.endswith(suffix): parsed_data[cls.SYMBOL_KEY] = symbol.split(suffix)[0] return @classmethod def parse_signal_data(cls, signal_data: str, errors: list) -> dict: if isinstance(signal_data, dict): # already parsed: return a deep copy to avoid modifying the original data return copy.deepcopy(signal_data) parsed_data = {} # replace all split char by a single one splittable_data = signal_data final_split_char = cls.PARAM_SEPARATORS[0] for split_char in cls.PARAM_SEPARATORS[1:]: splittable_data = splittable_data.replace(split_char, final_split_char) for line in splittable_data.split(final_split_char): if not line.strip(): # ignore empty lines continue values = line.split("=") try: value = values[1].strip() # restore booleans lower_val = value.lower() if lower_val in ("true", "false"): value = lower_val == "true" parsed_data[values[0].strip()] = value except IndexError: errors.append(f"Invalid signal line in trading view signal, ignoring it. Line: \"{line}\"") cls._adapt_symbol(parsed_data) return parsed_data @classmethod def is_compatible_trading_type(cls, parsed_signal: dict, trading_type: trading_enums.ExchangeTypes) -> bool: if parsed_trading_type := parsed_signal.get(cls.TRADING_TYPE_KEY): return parsed_trading_type == trading_type.value return True def _log_error_message_if_relevant(self, parsed_data: dict, signal_data: str): # only log error messages on one TradingViewSignalsTradingMode instance to avoid logging errors multiple times if self.is_first_trading_mode_on_this_matrix(): all_trading_modes = trading_modes.get_trading_modes_of_this_type_on_this_matrix(self) # Can log error message: this is the first trading mode on this matrix. # Each is notified by signals and only this one will log errors to avoid duplicating logs if not any( trading_mode.is_relevant_signal(parsed_data) for trading_mode in all_trading_modes ): # only log error if the signal is not relevant to any other trading mode on this matrix enabled_exchanges = set() enabled_symbols = set() for trading_mode in all_trading_modes: enabled_exchanges.add(trading_mode.exchange_manager.exchange_name) enabled_symbols.add(f"{trading_mode.str_symbol} (or {self.merged_simple_symbol})") self.logger.error( f"Ignored TradingView alert - unrelated to profile exchanges: {', '.join(enabled_exchanges)} and symbols: {', '.join(enabled_symbols)} (alert: {signal_data})" ) def is_relevant_signal(self, parsed_data: dict) -> bool: if not self.is_compatible_trading_type(parsed_data, trading_exchanges.get_exchange_type(self.exchange_manager)): return False elif parsed_data[self.EXCHANGE_KEY].lower() not in self.exchange_manager.exchange_name: return False elif parsed_data[self.SYMBOL_KEY] not in (self.merged_simple_symbol, self.str_symbol): return False return True async def _trading_view_signal_callback(self, data): signal_data = data.get("metadata", "") errors = [] parsed_data = self.parse_signal_data(signal_data, errors) for error in errors: self.logger.error(error) try: if self.is_relevant_signal(parsed_data): await self.producers[0].signal_callback(parsed_data, script_keywords.get_base_context(self)) else: self._log_error_message_if_relevant(parsed_data, signal_data) except (trading_errors.InvalidArgumentError, trading_errors.InvalidCancelPolicyError) as e: self.logger.error(f"Error when processing trading view signal: {e} (signal: {signal_data})") except trading_errors.MissingFunds as e: self.logger.error(f"Error when processing trading view signal: not enough funds: {e} (signal: {signal_data})") except KeyError as e: self.logger.error(f"Error when processing trading view signal: missing {e} required value (signal: {signal_data})") except Exception as e: self.logger.error( f"Unexpected error when processing trading view signal: {e} {e.__class__.__name__} (signal: {signal_data})" ) @classmethod def get_is_symbol_wildcard(cls) -> bool: return False @staticmethod def is_backtestable(): return False class TradingViewSignalsModeConsumer(daily_trading_mode.DailyTradingModeConsumer): def __init__(self, trading_mode): super().__init__(trading_mode) self.QUANTITY_MIN_PERCENT = decimal.Decimal(str(0.1)) self.QUANTITY_MAX_PERCENT = decimal.Decimal(str(0.9)) self.QUANTITY_MARKET_MIN_PERCENT = decimal.Decimal(str(0.5)) self.QUANTITY_MARKET_MAX_PERCENT = trading_constants.ONE self.QUANTITY_BUY_MARKET_ATTENUATION = decimal.Decimal(str(0.2)) self.BUY_LIMIT_ORDER_MAX_PERCENT = decimal.Decimal(str(0.995)) self.BUY_LIMIT_ORDER_MIN_PERCENT = decimal.Decimal(str(0.99)) self.USE_CLOSE_TO_CURRENT_PRICE = True self.CLOSE_TO_CURRENT_PRICE_DEFAULT_RATIO = decimal.Decimal(str(trading_mode.trading_config.get("close_to_current_price_difference", 0.02))) self.BUY_WITH_MAXIMUM_SIZE_ORDERS = trading_mode.trading_config.get("use_maximum_size_orders", False) self.SELL_WITH_MAXIMUM_SIZE_ORDERS = trading_mode.trading_config.get("use_maximum_size_orders", False) self.USE_STOP_ORDERS = False class TradingViewSignalsModeProducer(daily_trading_mode.DailyTradingModeProducer): def __init__(self, channel, config, trading_mode, exchange_manager): super().__init__(channel, config, trading_mode, exchange_manager) self.EVAL_BY_STATES = { trading_enums.EvaluatorStates.LONG: -0.6, trading_enums.EvaluatorStates.SHORT: 0.6, trading_enums.EvaluatorStates.VERY_LONG: -1, trading_enums.EvaluatorStates.VERY_SHORT: 1, trading_enums.EvaluatorStates.NEUTRAL: 0, } def get_channels_registration(self): # do not register on matrix or candles channels return [] async def set_final_eval(self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame, trigger_source: str): # Ignore matrix calls pass def _parse_pre_update_order_details(self, parsed_data): return { TradingViewSignalsModeConsumer.LEVERAGE: parsed_data.get(TradingViewSignalsTradingMode.LEVERAGE, None), } async def _parse_order_details(self, ctx, parsed_data): side = parsed_data[TradingViewSignalsTradingMode.SIGNAL_KEY].casefold() order_type = parsed_data.get(TradingViewSignalsTradingMode.ORDER_TYPE_SIGNAL, "").casefold() order_exchange_creation_params = { param_name.split(TradingViewSignalsTradingMode.PARAM_PREFIX_KEY)[1]: param_value for param_name, param_value in parsed_data.items() if param_name.startswith(TradingViewSignalsTradingMode.PARAM_PREFIX_KEY) } parsed_side = None if side == TradingViewSignalsTradingMode.SELL_SIGNAL: parsed_side = trading_enums.TradeOrderSide.SELL.value if order_type == TradingViewSignalsTradingMode.MARKET_SIGNAL: state = trading_enums.EvaluatorStates.VERY_SHORT elif order_type in (TradingViewSignalsTradingMode.LIMIT_SIGNAL, TradingViewSignalsTradingMode.STOP_SIGNAL): state = trading_enums.EvaluatorStates.SHORT else: state = trading_enums.EvaluatorStates.VERY_SHORT if self.trading_mode.USE_MARKET_ORDERS \ else trading_enums.EvaluatorStates.SHORT elif side == TradingViewSignalsTradingMode.BUY_SIGNAL: parsed_side = trading_enums.TradeOrderSide.BUY.value if order_type == TradingViewSignalsTradingMode.MARKET_SIGNAL: state = trading_enums.EvaluatorStates.VERY_LONG elif order_type in (TradingViewSignalsTradingMode.LIMIT_SIGNAL, TradingViewSignalsTradingMode.STOP_SIGNAL): state = trading_enums.EvaluatorStates.LONG else: state = trading_enums.EvaluatorStates.VERY_LONG if self.trading_mode.USE_MARKET_ORDERS \ else trading_enums.EvaluatorStates.LONG elif side == TradingViewSignalsTradingMode.CANCEL_SIGNAL: state = trading_enums.EvaluatorStates.NEUTRAL else: raise trading_errors.InvalidArgumentError( f"Unknown signal: {parsed_data[TradingViewSignalsTradingMode.SIGNAL_KEY]}, full data= {parsed_data}" ) target_price = 0 if order_type == TradingViewSignalsTradingMode.MARKET_SIGNAL else ( await self._parse_element(ctx, parsed_data, TradingViewSignalsTradingMode.PRICE_KEY, 0, True)) stop_price = await self._parse_element( ctx, parsed_data, TradingViewSignalsTradingMode.STOP_PRICE_KEY, math.nan, True ) tp_price = await self._parse_element( ctx, parsed_data, TradingViewSignalsTradingMode.TAKE_PROFIT_PRICE_KEY, math.nan, True ) additional_tp_volume_ratios = [] if first_volume := await self._parse_element( ctx, parsed_data, TradingViewSignalsTradingMode.TAKE_PROFIT_VOLUME_RATIO_KEY, 0, False ): additional_tp_volume_ratios.append(first_volume) additional_tp_prices = await self._parse_additional_decimal_elements( ctx, parsed_data, f"{TradingViewSignalsTradingMode.TAKE_PROFIT_PRICE_KEY}_", math.nan, True ) additional_tp_volume_ratios += await self._parse_additional_decimal_elements( ctx, parsed_data, f"{TradingViewSignalsTradingMode.TAKE_PROFIT_VOLUME_RATIO_KEY}_", 0, False ) allow_holdings_adaptation = parsed_data.get(TradingViewSignalsTradingMode.ALLOW_HOLDINGS_ADAPTATION_KEY, False) reduce_only = parsed_data.get(TradingViewSignalsTradingMode.REDUCE_ONLY_KEY, False) amount = await self._parse_volume( ctx, parsed_data, parsed_side, target_price, allow_holdings_adaptation, reduce_only ) trailing_profile = parsed_data.get(TradingViewSignalsTradingMode.TRAILING_PROFILE) maybe_cancel_policy, cancel_policy_params = self._parse_cancel_policy(parsed_data) order_data = { TradingViewSignalsModeConsumer.PRICE_KEY: target_price, TradingViewSignalsModeConsumer.VOLUME_KEY: amount, TradingViewSignalsModeConsumer.STOP_PRICE_KEY: stop_price, TradingViewSignalsModeConsumer.STOP_ONLY: order_type == TradingViewSignalsTradingMode.STOP_SIGNAL, TradingViewSignalsModeConsumer.TAKE_PROFIT_PRICE_KEY: tp_price, TradingViewSignalsModeConsumer.ADDITIONAL_TAKE_PROFIT_PRICES_KEY: additional_tp_prices, TradingViewSignalsModeConsumer.ADDITIONAL_TAKE_PROFIT_VOLUME_RATIOS_KEY: additional_tp_volume_ratios, TradingViewSignalsModeConsumer.REDUCE_ONLY_KEY: reduce_only, TradingViewSignalsModeConsumer.TAG_KEY: parsed_data.get(TradingViewSignalsTradingMode.TAG_KEY, None), TradingViewSignalsModeConsumer.TRAILING_PROFILE: trailing_profile.casefold() if trailing_profile else None, TradingViewSignalsModeConsumer.CANCEL_POLICY: maybe_cancel_policy, TradingViewSignalsModeConsumer.CANCEL_POLICY_PARAMS: cancel_policy_params, TradingViewSignalsModeConsumer.EXCHANGE_ORDER_IDS: parsed_data.get(TradingViewSignalsTradingMode.EXCHANGE_ORDER_IDS, None), TradingViewSignalsModeConsumer.LEVERAGE: parsed_data.get(TradingViewSignalsTradingMode.LEVERAGE, None), TradingViewSignalsModeConsumer.ORDER_EXCHANGE_CREATION_PARAMS: order_exchange_creation_params, } return state, order_data def _parse_cancel_policy(self, parsed_data): if policy := parsed_data.get(TradingViewSignalsTradingMode.CANCEL_POLICY, None): lowercase_policy = policy.casefold() if not _CANCEL_POLICIES_CACHE: _CANCEL_POLICIES_CACHE.update({ policy.__name__.casefold(): policy.__name__ for policy in tentacles_management.get_all_classes_from_parent(trading_personal_data.OrderCancelPolicy) }) try: policy_class = _CANCEL_POLICIES_CACHE[lowercase_policy] policy_params = parsed_data.get(TradingViewSignalsTradingMode.CANCEL_POLICY_PARAMS) parsed_policy_params = json.loads(policy_params.replace("'", '"')) if isinstance(policy_params, str) else policy_params return policy_class, parsed_policy_params except KeyError: raise trading_errors.InvalidCancelPolicyError( f"Unknown cancel policy: {policy}. Available policies: {', '.join(_CANCEL_POLICIES_CACHE.keys())}" ) return None, None async def _parse_additional_decimal_elements(self, ctx, parsed_data, element_prefix, default, is_price): values: list[decimal.Decimal] = [] for key, value in parsed_data.items(): if key.startswith(element_prefix) and len(key.split(element_prefix)) == 2: values.append(await self._parse_element(ctx, parsed_data, key, default, is_price)) return values async def _parse_element(self, ctx, parsed_data, key, default, is_price)-> decimal.Decimal: target_value = decimal.Decimal(str(default)) value = parsed_data.get(key, 0) if is_price: if input_price_or_offset := value: target_value = await script_keywords.get_price_with_offset( ctx, input_price_or_offset, use_delta_type_as_flat_value=True ) else: target_value = decimal.Decimal(str(value)) return target_value async def _parse_volume(self, ctx, parsed_data, side, target_price, allow_holdings_adaptation, reduce_only): user_volume = str(parsed_data.get(TradingViewSignalsTradingMode.VOLUME_KEY, 0)) if user_volume == "0": return trading_constants.ZERO return await script_keywords.get_amount_from_input_amount( context=ctx, input_amount=user_volume, side=side, reduce_only=reduce_only, is_stop_order=False, use_total_holding=False, target_price=target_price, # raise when not enough funds to create an order according to user input allow_holdings_adaptation=allow_holdings_adaptation, ) async def signal_callback(self, parsed_data: dict, ctx): _, dependencies = await self.apply_cancel_policies() if self.trading_mode.CANCEL_PREVIOUS_ORDERS: # cancel open orders _, new_dependencies = await self.cancel_symbol_open_orders(self.trading_mode.symbol) if new_dependencies: if dependencies: dependencies.extend(new_dependencies) else: dependencies = new_dependencies pre_update_data = self._parse_pre_update_order_details(parsed_data) await self._process_pre_state_update_actions(ctx, pre_update_data) state, order_data = await self._parse_order_details(ctx, parsed_data) self.final_eval = self.EVAL_BY_STATES[state] # Use daily trading mode state system await self._set_state( self.trading_mode.cryptocurrency, ctx.symbol, state, order_data, dependencies=dependencies ) async def _process_pre_state_update_actions(self, context, data: dict): try: if leverage := data.get(TradingViewSignalsModeConsumer.LEVERAGE): await self.trading_mode.set_leverage(context.symbol, None, decimal.Decimal(str(leverage))) except Exception as err: self.logger.exception( err, True, f"Error when processing pre_state_update_actions: {err} (data: {data})" ) async def _set_state( self, cryptocurrency: str, symbol: str, new_state, order_data, dependencies: typing.Optional[commons_signals.SignalDependencies] = None ): async with self.trading_mode_trigger(): self.state = new_state self.logger.info(f"[{symbol}] new state: {self.state.name}") # if new state is not neutral --> cancel orders and create new else keep orders if new_state is not trading_enums.EvaluatorStates.NEUTRAL: # call orders creation from consumers await self.submit_trading_evaluation(cryptocurrency=cryptocurrency, symbol=symbol, time_frame=None, final_note=self.final_eval, state=self.state, data=order_data, dependencies=dependencies) # send_notification if not self.exchange_manager.is_backtesting: await self._send_alert_notification(symbol, new_state) else: await self.cancel_orders_from_order_data(symbol, order_data) async def cancel_orders_from_order_data(self, symbol: str, order_data) -> tuple[bool, typing.Optional[commons_signals.SignalDependencies]]: if not self.trading_mode.consumers: return False, None exchange_ids = order_data.get(TradingViewSignalsModeConsumer.EXCHANGE_ORDER_IDS, None) cancel_order_raw_side = order_data.get( TradingViewSignalsModeConsumer.ORDER_EXCHANGE_CREATION_PARAMS, {}).get( TradingViewSignalsTradingMode.SIDE_PARAM_KEY, None) cancel_order_side = trading_enums.TradeOrderSide.BUY if cancel_order_raw_side == trading_enums.TradeOrderSide.BUY.value \ else trading_enums.TradeOrderSide.SELL if cancel_order_raw_side == trading_enums.TradeOrderSide.SELL.value else None cancel_order_tag = order_data.get(TradingViewSignalsModeConsumer.TAG_KEY, None) # cancel open orders return await self.cancel_symbol_open_orders( symbol, side=cancel_order_side, tag=cancel_order_tag, exchange_order_ids=exchange_ids ) ================================================ FILE: metadata.yaml ================================================ author: DrakkarSoftware name: base short-name: base repository: OctoBot-Tentacles version: VERSION_PLACEHOLDER description: > This package contains default evaluators, strategies, utilitary modules, interfaces and trading modes. tags: - officials ================================================ FILE: octobot_config.json ================================================ { "backtesting": { "enabled": false, "files": [] }, "crypto-currencies":{ "Bitcoin": { "pairs" : ["BTC/USDT"] } }, "exchanges": { "binance": { "api-key": "your-api-key-here", "api-secret": "your-api-secret-here" } }, "services": {}, "notification":{ "global-info": true, "price-alerts": true, "trades": true, "notification-type": [] }, "trading":{ "risk": 0.5, "reference-market": "BTC" }, "trader":{ "enabled": false }, "trader-simulator":{ "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } } } ================================================ FILE: profiles/arbitrage_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true }, "coinbase": { "enabled": true }, "kucoin": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 3, "description": "ArbitrageTrading is watching prices of the configured trading pairs across the available exchanges to find arbitrage opportunities.", "id": "arbitrage_trading", "name": "Arbitrage Trading", "read_only": true } } ================================================ FILE: profiles/arbitrage_trading/specific_config/ArbitrageTradingMode.json ================================================ { "exchanges_to_trade_on": [], "minimal_price_delta_percent": 0.35, "portfolio_percent_per_trade": 25, "required_strategies": [], "stop_loss_delta_percent": 0.1 } ================================================ FILE: profiles/arbitrage_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "ArbitrageTradingMode": true } } } ================================================ FILE: profiles/copy_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 1, "description": "Copy Trading is following moves from the specified trading strategy.", "id": "copy_trading", "name": "Copy Trading", "read_only": true } } ================================================ FILE: profiles/copy_trading/specific_config/RemoteTradingSignalsTradingMode.json ================================================ { "trading_strategy": "trading_strategy_identifier", "required_strategies": [] } ================================================ FILE: profiles/copy_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "RemoteTradingSignalsTradingMode": true } } } ================================================ FILE: profiles/daily_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 3, "complexity": 1, "description": "DailyTrading is a versatile profile that catches buy and sell opportunities using RSI and moving average indicators.", "id": "daily_trading", "name": "DailyTrading", "read_only": true } } ================================================ FILE: profiles/daily_trading/specific_config/DailyTradingMode.json ================================================ { "close_to_current_price_difference": 0.005, "default_config": [ "SimpleStrategyEvaluator" ], "required_strategies": [ "SimpleStrategyEvaluator", "TechnicalAnalysisStrategyEvaluator" ], "required_strategies_min_count": 1, "sell_with_maximum_size_orders": false, "buy_with_maximum_size_orders": false, "use_prices_close_to_current_price": false, "disable_buy_orders": false, "disable_sell_orders": false, "use_stop_orders": true } ================================================ FILE: profiles/daily_trading/specific_config/SimpleStrategyEvaluator.json ================================================ { "default_config": [ "DoubleMovingAverageTrendEvaluator", "RSIMomentumEvaluator" ], "required_evaluators": [ "*" ], "required_time_frames": [ "1h", "4h", "1d" ], "social_evaluators_notification_timeout": 3600, "re_evaluate_TA_when_social_or_realtime_notification": true, "background_social_evaluators": [ "RedditForumEvaluator" ] } ================================================ FILE: profiles/daily_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Evaluator": { "DoubleMovingAverageTrendEvaluator": true, "RSIMomentumEvaluator": true, "SimpleStrategyEvaluator": true }, "Trading": { "DailyTradingMode": true } } } ================================================ FILE: profiles/dip_analyser/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] }, "Ethereum": { "enabled": true, "pairs": [ "ETH/BTC", "ETH/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 2, "complexity": 1, "description": "DipAnalyser is a profile adapted to volatile markets. It will look for local market bottoms, weight them and buy these bottoms. It never sells except after a buy order is filled.", "id": "dip_analyser", "name": "Dip Analyser", "read_only": true } } ================================================ FILE: profiles/dip_analyser/specific_config/DipAnalyserStrategyEvaluator.json ================================================ { "default_config": [ "KlingerOscillatorReversalConfirmationMomentumEvaluator", "RSIWeightMomentumEvaluator" ], "required_evaluators": [ "InstantFluctuationsEvaluator", "KlingerOscillatorReversalConfirmationMomentumEvaluator", "RSIWeightMomentumEvaluator" ], "required_time_frames": [ "4h" ] } ================================================ FILE: profiles/dip_analyser/specific_config/DipAnalyserTradingMode.json ================================================ { "required_strategies": [ "DipAnalyserStrategyEvaluator" ], "sell_orders_count": 3, "light_weight_price_multiplier": 1.04, "medium_weight_price_multiplier": 1.07, "heavy_weight_price_multiplier": 1.1, "light_weight_volume_multiplier": 0.5, "medium_weight_volume_multiplier": 0.7, "heavy_weight_volume_multiplier": 1 } ================================================ FILE: profiles/dip_analyser/specific_config/InstantFluctuationsEvaluator.json ================================================ { "price_difference_threshold_percent": 1, "volume_difference_threshold_percent": 400, "time_frame": "1m" } ================================================ FILE: profiles/dip_analyser/specific_config/RSIWeightMomentumEvaluator.json ================================================ { "period": 14, "slow_eval_count": 16, "fast_eval_count": 4, "RSI_to_weight": [ { "slow_threshold": 30, "fast_thresholds": [ { "fast_threshold" : 20, "weights": { "price": 2, "volume": 2 } }, { "fast_threshold" : 30, "weights": { "price": 1, "volume": 1 } } ] }, { "slow_threshold": 35, "fast_thresholds": [ { "fast_threshold" : 20, "weights": { "price": 3, "volume": 3 } }, { "fast_threshold" : 35, "weights": { "price": 1, "volume": 1 } } ] }, { "slow_threshold": 45, "fast_thresholds": [ { "fast_threshold" : 20, "weights": { "price": 3, "volume": 3 } }, { "fast_threshold" : 40, "weights": { "price": 2, "volume": 1 } } ] }, { "slow_threshold": 55, "fast_thresholds": [ { "fast_threshold" : 45, "weights": { "price": 1, "volume": 1 } } ] }, { "slow_threshold": 65, "fast_thresholds": [ { "fast_threshold" : 45, "weights": { "price": 1, "volume": 1 } }, { "fast_threshold" : 55, "weights": { "price": 3, "volume": 2 } }, { "fast_threshold" : 60, "weights": { "price": 2, "volume": 1 } } ] }, { "slow_threshold": 70, "fast_thresholds": [ { "fast_threshold" : 55, "weights": { "price": 3, "volume": 2 } }, { "fast_threshold" : 70, "weights": { "price": 2, "volume": 2 } } ] } ] } ================================================ FILE: profiles/dip_analyser/tentacles_config.json ================================================ { "tentacle_activation": { "Evaluator": { "DipAnalyserStrategyEvaluator": true, "InstantFluctuationsEvaluator": true, "KlingerOscillatorReversalConfirmationMomentumEvaluator": true, "RSIWeightMomentumEvaluator": true }, "Trading": { "DipAnalyserTradingMode": true } } } ================================================ FILE: profiles/gpt_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true, "exchange-type": "spot" } }, "trader": { "enabled": false, "load-trade-history": true }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 1 } }, "profile": { "avatar": "ChatGPT_logo.svg", "complexity": 2, "description": "GPT Smart DCA uses ChatGPT to predict the market. It can be used to trade directly based on the profile's DCA Trading mode configuration.\nConfigure the GPTEvaluator to customize the way market data are sent to ChatGPT and the DCATradingMode to change how entries and exits should be created.", "id": "gpt_trading", "imported": false, "name": "GPT Trading", "origin_url": null, "read_only": true, "risk": 3 } } ================================================ FILE: profiles/gpt_trading/specific_config/DCATradingMode.json ================================================ { "buy_order_amount": "8%t", "cancel_open_orders_at_each_entry": true, "default_config": [ "SimpleStrategyEvaluator" ], "entry_limit_orders_price_percent": 1.3, "exit_limit_orders_price_percent": 2, "minutes_before_next_buy": 10080, "required_strategies": [ "SimpleStrategyEvaluator", "TechnicalAnalysisStrategyEvaluator" ], "secondary_entry_orders_amount": "8%t", "secondary_entry_orders_count": 1, "secondary_entry_orders_price_percent": 1.3, "secondary_exit_orders_count": 1, "secondary_exit_orders_price_percent": 0.5, "trigger_mode": "Maximum evaluators signals based", "use_init_entry_orders": false, "use_market_entry_orders": false, "use_secondary_entry_orders": true, "use_secondary_exit_orders": false, "use_stop_losses": false, "use_take_profit_exit_orders": true } ================================================ FILE: profiles/gpt_trading/specific_config/GPTEvaluator.json ================================================ { "GPT_model": "gpt-3.5-turbo", "indicator": "No indicator: raw candles price data", "max_confidence_threshold": 60, "period": 20, "source": "Full candle (For no indicator only)", "allow_reevaluation": false, "max_gpt_tokens": -1 } ================================================ FILE: profiles/gpt_trading/specific_config/SimpleStrategyEvaluator.json ================================================ { "background_social_evaluators": [ "RedditForumEvaluator" ], "default_config": [ "DoubleMovingAverageTrendEvaluator", "RSIMomentumEvaluator" ], "re_evaluate_TA_when_social_or_realtime_notification": true, "required_candles_count": 1000, "required_evaluators": [ "*" ], "required_time_frames": [ "4h" ], "social_evaluators_notification_timeout": 3600 } ================================================ FILE: profiles/gpt_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Evaluator": { "GPTEvaluator": true, "SimpleStrategyEvaluator": true }, "Trading": { "DCATradingMode": true } } } ================================================ FILE: profiles/grid_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 1, "description": "GridTrading is a profile configured to create a pre-defined amount of buy and sell orders at fixed intervals to profit from any market move. When an order is filled, a mirror order is instantly created and generates profit when completed. GridTrading is a simpler version of the StaggeredOrdersTradingMode.", "id": "grid_trading", "name": "Grid Trading", "read_only": true } } ================================================ FILE: profiles/grid_trading/specific_config/GridTradingMode.json ================================================ { "required_strategies": [], "pair_settings": [ { "pair": "BTC/USDT", "flat_spread": 2000, "flat_increment": 1000, "buy_orders_count": 25, "sell_orders_count": 25, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "ignore_exchange_fees": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": false, "enable_trailing_down": false, "funds_redispatch_interval": 24 }, { "pair": "ADA/ETH", "flat_spread": 0.00002, "flat_increment": 0.00001, "buy_orders_count": 25, "sell_orders_count": 25, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "ignore_exchange_fees": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": false, "enable_trailing_down": false, "funds_redispatch_interval": 24 }, { "pair": "ETH/USDT", "flat_spread": 10, "flat_increment": 5, "buy_orders_count": 25, "sell_orders_count": 25, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "ignore_exchange_fees": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": false, "enable_trailing_down": false, "funds_redispatch_interval": 24 } ] } ================================================ FILE: profiles/grid_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "GridTradingMode": true } } } ================================================ FILE: profiles/index_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] }, "Ethereum": { "enabled": true, "pairs": [ "ETH/USDT" ] }, "Solana": { "enabled": true, "pairs": [ "SOL/USDT" ] } }, "exchanges": { "binance": { "enabled": true, "exchange-type": "spot" } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "auto_update": false, "avatar": "default_profile.png", "complexity": 1, "description": "Index Trading will maintain a custom crypto index using each enabled traded coin.", "extra_backtesting_time_frames": [], "id": "index_trading", "imported": false, "name": "Index trading", "origin_url": null, "read_only": true, "risk": 1, "slug": "", "type": "live" } } ================================================ FILE: profiles/index_trading/specific_config/IndexTradingMode.json ================================================ { "index_content": [], "rebalance_trigger_min_percent": 5, "refresh_interval": 1, "required_strategies": [] } ================================================ FILE: profiles/index_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "IndexTradingMode": true } } } ================================================ FILE: profiles/market_making/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDC" ] } }, "exchanges": { "binance": { "enabled": true, "exchange-type": "spot" } }, "trader": { "enabled": false, "load-trade-history": true }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 0.001, "USDC": 1000 } }, "trading": { "reference-market": "USDC", "risk": 1.0 } }, "profile": { "auto_update": false, "avatar": "default_profile.png", "complexity": 1, "description": "Market making will create and maintain an order book following a customized market making strategy.", "extra_backtesting_time_frames": [], "id": "market_making", "imported": false, "name": "Market making", "origin_url": null, "read_only": true, "risk": 3, "slug": "", "type": "live" } } ================================================ FILE: profiles/market_making/specific_config/MarketMakingTradingMode.json ================================================ { "asks_count": 3, "bids_count": 3, "max_spread": 5, "min_spread": 2, "reference_exchange": "local", "required_strategies": [] } ================================================ FILE: profiles/market_making/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "MarketMakingTradingMode": true } } } ================================================ FILE: profiles/non-trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": true }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "USDT": 10000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 1, "description": "Non-Trading is a profile that does not trade. It will not create any order. Use this profile to run OctoBot without creating any order.", "id": "default", "name": "Non-Trading", "read_only": true } } ================================================ FILE: profiles/non-trading/specific_config/BlankStrategyEvaluator.json ================================================ { "required_time_frames" : ["1h"], "required_evaluators" : ["*"], "required_candles_count" : 200, "default_config" : [] } ================================================ FILE: profiles/non-trading/tentacles_config.json ================================================ { "tentacle_activation": { "Evaluator": { "BlankStrategyEvaluator": true }, "Trading": { "BlankTradingMode": true } } } ================================================ FILE: profiles/signal_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] }, "Ethereum": { "enabled": true, "pairs": [ "ETH/BTC", "ETH/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 3, "complexity": 1, "description": "SignalTrading is adapted to liquid and relatively flat markets. It will try to find reversals and trade them.", "id": "signal_trading", "name": "Signal Trading", "read_only": true } } ================================================ FILE: profiles/signal_trading/specific_config/MoveSignalsStrategyEvaluator.json ================================================ { "required_time_frames" : ["30m", "1h", "4h"], "required_evaluators" : ["InstantFluctuationsEvaluator", "KlingerOscillatorMomentumEvaluator", "BBMomentumEvaluator"], "default_config" : ["KlingerOscillatorMomentumEvaluator", "BBMomentumEvaluator"] } ================================================ FILE: profiles/signal_trading/specific_config/SignalTradingMode.json ================================================ { "close_to_current_price_difference": 0.02, "required_strategies": [ "MoveSignalsStrategyEvaluator" ], "use_maximum_size_orders": false, "use_prices_close_to_current_price": false, "use_stop_orders": true } ================================================ FILE: profiles/signal_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Evaluator": { "KlingerOscillatorMomentumEvaluator": true, "MoveSignalsStrategyEvaluator": true, "InstantFluctuationsEvaluator": true, "BBMomentumEvaluator": true }, "Trading": { "SignalTradingMode": true } } } ================================================ FILE: profiles/simple_dca/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 1, "description": "Simple DCA (Dollar cost averaging) is a profile that can help you lower the amount you pay for investments and minimize risk. Instead of purchasing investments at a single price point, with dollar cost averaging you buy in smaller amounts at regular intervals, regardless of price. Over the long term, dollar cost averaging can help lower your investment costs and boost your returns.", "id": "simple_dca", "name": "Simple DCA", "read_only": true } } ================================================ FILE: profiles/simple_dca/specific_config/DCATradingMode.json ================================================ { "buy_order_amount": "5%t", "default_config": [], "required_strategies": [], "cancel_open_orders_at_each_entry": false, "minutes_before_next_buy": 10080, "trigger_mode": "Time based", "use_market_entry_orders": true, "use_secondary_entry_orders": false, "use_stop_losses": false, "use_take_profit_exit_orders": false, "enable_health_check": false, "health_check_orphan_funds_threshold": 15 } ================================================ FILE: profiles/simple_dca/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "DCATradingMode": true } } } ================================================ FILE: profiles/smart_dca/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true, "exchange-type": "spot" } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 0, "USDT": 1000, "ETH": 0 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "complexity": 3, "description": "Smart DCA (Dollar cost averaging) is a profile that trades by quickly entering and exiting the market using up to 3 entries each split into 2 exits. Each entry uses 5% of the portfolio and is signaled when the price is bellow its by EMA values.", "imported": false, "name": "Smart DCA", "origin_url": null, "read_only": true, "risk": 2, "id": "smart_dca" } } ================================================ FILE: profiles/smart_dca/specific_config/DCATradingMode.json ================================================ { "buy_order_amount": "5%t", "default_config": [ "SimpleStrategyEvaluator" ], "required_strategies": [ "SimpleStrategyEvaluator" ], "entry_limit_orders_price_percent": 0.2, "exit_limit_orders_price_percent": 0.35, "minutes_before_next_buy": 10080, "secondary_entry_orders_amount": "5%t", "secondary_entry_orders_count": 2, "secondary_entry_orders_price_percent": 0.5, "secondary_exit_orders_count": 1, "secondary_exit_orders_price_percent": 0.5, "trigger_mode": "Maximum evaluators signals based", "use_market_entry_orders": false, "use_secondary_entry_orders": true, "use_secondary_exit_orders": true, "use_stop_losses": false, "use_take_profit_exit_orders": true, "enable_health_check": false, "health_check_orphan_funds_threshold": 15 } ================================================ FILE: profiles/smart_dca/specific_config/EMAMomentumEvaluator.json ================================================ { "period_length": 9, "price_threshold_percent": 0 } ================================================ FILE: profiles/smart_dca/specific_config/SimpleStrategyEvaluator.json ================================================ { "background_social_evaluators": [ "RedditForumEvaluator" ], "default_config": [ "DoubleMovingAverageTrendEvaluator", "RSIMomentumEvaluator" ], "re_evaluate_TA_when_social_or_realtime_notification": true, "required_candles_count": 1000, "required_evaluators": [ "*" ], "required_time_frames": [ "4h" ], "social_evaluators_notification_timeout": 3600 } ================================================ FILE: profiles/smart_dca/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "DCATradingMode": true }, "Evaluator": { "EMAMomentumEvaluator": true, "SimpleStrategyEvaluator": true } } } ================================================ FILE: profiles/staggered_orders_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 3, "description": "StaggeredOrdersTrading is a profile configured to create a buy and sell orders at fixed intervals on a specific price range to profit from any market move. When an order is filled, a mirror order is instantly created and generates profit when completed.", "id": "staggered_orders_trading", "name": "Staggered Orders Trading", "read_only": true } } ================================================ FILE: profiles/staggered_orders_trading/specific_config/StaggeredOrdersTradingMode.json ================================================ { "required_strategies": [], "pair_settings": [ { "pair": "BTC/USDT", "mode": "mountain", "spread_percent": 6, "increment_percent": 3, "lower_bound": 30000, "upper_bound": 60000, "allow_instant_fill": true, "operational_depth": 100, "mirror_order_delay": 0, "ignore_exchange_fees": false, "use_existing_orders_only": false }, { "pair": "ADA/ETH", "mode": "mountain", "spread_percent": 6, "increment_percent": 3, "lower_bound": 0.0003, "upper_bound": 0.0007, "allow_instant_fill": true, "operational_depth": 50, "mirror_order_delay": 0, "ignore_exchange_fees": false, "use_existing_orders_only": false }, { "pair": "ETH/USDT", "mode": "mountain", "spread_percent": 0.7, "increment_percent": 0.3, "lower_bound": 400, "upper_bound": 500, "allow_instant_fill": true, "operational_depth": 50, "mirror_order_delay": 0, "ignore_exchange_fees": false, "use_existing_orders_only": false } ] } ================================================ FILE: profiles/staggered_orders_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "StaggeredOrdersTradingMode": true } } } ================================================ FILE: profiles/tradingview_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] }, "Ethereum": { "enabled": true, "pairs": [ "ETH/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 2, "complexity": 3, "description": "TradingViewSignalsTrading is a profile configured to react on signals from tradingview.com. It requires to setup a trading view pro account and a webhook service. See how to configure your OctoBot for TradingView on https://www.octobot.cloud/guides/octobot-interfaces/tradingview", "id": "tradingview_trading", "name": "TradingView Signals Trading", "read_only": true } } ================================================ FILE: profiles/tradingview_trading/specific_config/TradingViewSignalsTradingMode.json ================================================ { "close_to_current_price_difference": 0.02, "required_strategies": [], "use_market_orders": true, "use_maximum_size_orders": true } ================================================ FILE: profiles/tradingview_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Services": { "TradingViewService": true, "TradingViewServiceFeed": true }, "Trading": { "TradingViewSignalsTradingMode": true } } } ================================================ FILE: profiles/trailing_grid_trading/profile.json ================================================ { "config": { "crypto-currencies": { "Bitcoin": { "enabled": true, "pairs": [ "BTC/USDT" ] } }, "exchanges": { "binance": { "enabled": true } }, "trader": { "enabled": false, "load-trade-history": false }, "trader-simulator": { "enabled": true, "fees": { "maker": 0.1, "taker": 0.1 }, "starting-portfolio": { "BTC": 10, "USDT": 1000 } }, "trading": { "reference-market": "USDT", "risk": 0.5 } }, "profile": { "avatar": "default_profile.png", "risk": 1, "complexity": 1, "description": "Trailing Grid Trading is a profile similar to the Grid Trading profile, except that it will create a trailing grid. When the BTC price will rise beyond the initial grid sell orders, the grid will automatically adapt to trade according to the updated price.", "id": "trailing_grid_trading", "name": "Trailing Grid Trading", "read_only": true } } ================================================ FILE: profiles/trailing_grid_trading/specific_config/GridTradingMode.json ================================================ { "required_strategies": [], "pair_settings": [ { "pair": "BTC/USDT", "flat_spread": 1300, "flat_increment": 800, "buy_orders_count": 15, "sell_orders_count": 15, "sell_funds": 0, "buy_funds": 0, "starting_price": 0, "buy_volume_per_order": 0, "sell_volume_per_order": 0, "ignore_exchange_fees": false, "use_fixed_volume_for_mirror_orders": false, "mirror_order_delay": 0, "use_existing_orders_only": false, "allow_funds_redispatch": false, "enable_trailing_up": true, "enable_trailing_down": false, "funds_redispatch_interval": 24 } ] } ================================================ FILE: profiles/trailing_grid_trading/tentacles_config.json ================================================ { "tentacle_activation": { "Trading": { "GridTradingMode": true } } } ================================================ FILE: scripts/clear_cloudflare_cache.py ================================================ import os import sys import requests CLOUDFLARE_ZONE = os.getenv("CLOUDFLARE_ZONE") CLOUDFLARE_TOKEN = os.getenv("CLOUDFLARE_TOKEN") S3_BUCKET_NAME = os.getenv("S3_BUCKET_NAME") def _send_purge_cache_request(url: str, cloudflare_token: str, urls_to_purge: list): with requests.post( url=url, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {cloudflare_token}", }, json={ "files": urls_to_purge } ) as resp: if resp.status_code == 200: print(f"Cache purged for {', '.join(urls_to_purge)}") else: print(f"Error when purging cache, status: {resp.status_code}, body: {resp.text}") def _get_tentacles_url(tentacle_url_identifier): if not S3_BUCKET_NAME: raise RuntimeError("Missing S3_BUCKET_NAME env variable") return os.getenv( "TENTACLES_URL", f"https://{S3_BUCKET_NAME}." f"{os.getenv('TENTACLES_OCTOBOT_ONLINE_URL', 'octobot.online')}/" f"officials/packages/full/base/" f"{tentacle_url_identifier}/" f"any_platform.zip" ) def clear_cache(tentacle_url_identifiers): if not CLOUDFLARE_ZONE: raise RuntimeError("Missing CLOUDFLARE_ZONE env variable") if not CLOUDFLARE_TOKEN: raise RuntimeError("Missing CLOUDFLARE_TOKEN env variable") # https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-url url = f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE}/purge_cache" _send_purge_cache_request( url, CLOUDFLARE_TOKEN, [ _get_tentacles_url(tentacle_url_identifier) for tentacle_url_identifier in tentacle_url_identifiers ] ) if __name__ == '__main__': clear_cache(sys.argv[1:])