Merge pull request #8459 from freqtrade/feat/kvstore
Add initial bot start time to /profit endpoint
This commit is contained in:
		| @@ -279,6 +279,7 @@ Return a summary of your profit/loss and performance. | ||||
| >   ∙ `33.095 EUR` | ||||
| > | ||||
| > **Total Trade Count:** `138` | ||||
| > **Bot started:** `2022-07-11 18:40:44` | ||||
| > **First Trade opened:** `3 days ago` | ||||
| > **Latest Trade opened:** `2 minutes ago` | ||||
| > **Avg. Duration:** `2:33:45` | ||||
| @@ -292,6 +293,7 @@ The relative profit of `15.2 Σ%` is be based on the starting capital - so in th | ||||
| Starting capital is either taken from the `available_capital` setting, or calculated by using current wallet size - profits. | ||||
| Profit Factor is calculated as gross profits / gross losses - and should serve as an overall metric for the strategy. | ||||
| Max drawdown corresponds to the backtesting metric `Absolute Drawdown (Account)` - calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`. | ||||
| Bot started date will refer to the date the bot was first started. For older bots, this will default to the first trade's open date. | ||||
|  | ||||
| ### /forceexit <trade_id> | ||||
|  | ||||
|   | ||||
| @@ -26,6 +26,7 @@ from freqtrade.exchange import (ROUND_DOWN, ROUND_UP, timeframe_to_minutes, time | ||||
| from freqtrade.misc import safe_value_fallback, safe_value_fallback2 | ||||
| from freqtrade.mixins import LoggingMixin | ||||
| from freqtrade.persistence import Order, PairLocks, Trade, init_db | ||||
| from freqtrade.persistence.key_value_store import set_startup_time | ||||
| from freqtrade.plugins.pairlistmanager import PairListManager | ||||
| from freqtrade.plugins.protectionmanager import ProtectionManager | ||||
| from freqtrade.resolvers import ExchangeResolver, StrategyResolver | ||||
| @@ -182,6 +183,7 @@ class FreqtradeBot(LoggingMixin): | ||||
|         performs startup tasks | ||||
|         """ | ||||
|         migrate_binance_futures_names(self.config) | ||||
|         set_startup_time() | ||||
|  | ||||
|         self.rpc.startup_messages(self.config, self.pairlists, self.protections) | ||||
|         # Update older trades with precision and precision mode | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| # flake8: noqa: F401 | ||||
|  | ||||
| from freqtrade.persistence.key_value_store import KeyStoreKeys, KeyValueStore | ||||
| from freqtrade.persistence.models import init_db | ||||
| from freqtrade.persistence.pairlock_middleware import PairLocks | ||||
| from freqtrade.persistence.trade_model import LocalTrade, Order, Trade | ||||
|   | ||||
							
								
								
									
										179
									
								
								freqtrade/persistence/key_value_store.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								freqtrade/persistence/key_value_store.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| from datetime import datetime, timezone | ||||
| from enum import Enum | ||||
| from typing import ClassVar, Optional, Union | ||||
|  | ||||
| from sqlalchemy import String | ||||
| from sqlalchemy.orm import Mapped, mapped_column | ||||
|  | ||||
| from freqtrade.persistence.base import ModelBase, SessionType | ||||
|  | ||||
|  | ||||
| ValueTypes = Union[str, datetime, float, int] | ||||
|  | ||||
|  | ||||
| class ValueTypesEnum(str, Enum): | ||||
|     STRING = 'str' | ||||
|     DATETIME = 'datetime' | ||||
|     FLOAT = 'float' | ||||
|     INT = 'int' | ||||
|  | ||||
|  | ||||
| class KeyStoreKeys(str, Enum): | ||||
|     BOT_START_TIME = 'bot_start_time' | ||||
|     STARTUP_TIME = 'startup_time' | ||||
|  | ||||
|  | ||||
| class _KeyValueStoreModel(ModelBase): | ||||
|     """ | ||||
|     Pair Locks database model. | ||||
|     """ | ||||
|     __tablename__ = 'KeyValueStore' | ||||
|     session: ClassVar[SessionType] | ||||
|  | ||||
|     id: Mapped[int] = mapped_column(primary_key=True) | ||||
|  | ||||
|     key: Mapped[KeyStoreKeys] = mapped_column(String(25), nullable=False, index=True) | ||||
|  | ||||
|     value_type: Mapped[ValueTypesEnum] = mapped_column(String(20), nullable=False) | ||||
|  | ||||
|     string_value: Mapped[Optional[str]] | ||||
|     datetime_value: Mapped[Optional[datetime]] | ||||
|     float_value: Mapped[Optional[float]] | ||||
|     int_value: Mapped[Optional[int]] | ||||
|  | ||||
|  | ||||
| class KeyValueStore(): | ||||
|     """ | ||||
|     Generic bot-wide, persistent key-value store | ||||
|     Can be used to store generic values, e.g. very first bot startup time. | ||||
|     Supports the types str, datetime, float and int. | ||||
|     """ | ||||
|  | ||||
|     @staticmethod | ||||
|     def store_value(key: KeyStoreKeys, value: ValueTypes) -> None: | ||||
|         """ | ||||
|         Store the given value for the given key. | ||||
|         :param key: Key to store the value for - can be used in get-value to retrieve the key | ||||
|         :param value: Value to store - can be str, datetime, float or int | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key).first() | ||||
|         if kv is None: | ||||
|             kv = _KeyValueStoreModel(key=key) | ||||
|         if isinstance(value, str): | ||||
|             kv.value_type = ValueTypesEnum.STRING | ||||
|             kv.string_value = value | ||||
|         elif isinstance(value, datetime): | ||||
|             kv.value_type = ValueTypesEnum.DATETIME | ||||
|             kv.datetime_value = value | ||||
|         elif isinstance(value, float): | ||||
|             kv.value_type = ValueTypesEnum.FLOAT | ||||
|             kv.float_value = value | ||||
|         elif isinstance(value, int): | ||||
|             kv.value_type = ValueTypesEnum.INT | ||||
|             kv.int_value = value | ||||
|         else: | ||||
|             raise ValueError(f'Unknown value type {kv.value_type}') | ||||
|         _KeyValueStoreModel.session.add(kv) | ||||
|         _KeyValueStoreModel.session.commit() | ||||
|  | ||||
|     @staticmethod | ||||
|     def delete_value(key: KeyStoreKeys) -> None: | ||||
|         """ | ||||
|         Delete the value for the given key. | ||||
|         :param key: Key to delete the value for | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key).first() | ||||
|         if kv is not None: | ||||
|             _KeyValueStoreModel.session.delete(kv) | ||||
|             _KeyValueStoreModel.session.commit() | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_value(key: KeyStoreKeys) -> Optional[ValueTypes]: | ||||
|         """ | ||||
|         Get the value for the given key. | ||||
|         :param key: Key to get the value for | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key).first() | ||||
|         if kv is None: | ||||
|             return None | ||||
|         if kv.value_type == ValueTypesEnum.STRING: | ||||
|             return kv.string_value | ||||
|         if kv.value_type == ValueTypesEnum.DATETIME and kv.datetime_value is not None: | ||||
|             return kv.datetime_value.replace(tzinfo=timezone.utc) | ||||
|         if kv.value_type == ValueTypesEnum.FLOAT: | ||||
|             return kv.float_value | ||||
|         if kv.value_type == ValueTypesEnum.INT: | ||||
|             return kv.int_value | ||||
|         # This should never happen unless someone messed with the database manually | ||||
|         raise ValueError(f'Unknown value type {kv.value_type}')  # pragma: no cover | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_string_value(key: KeyStoreKeys) -> Optional[str]: | ||||
|         """ | ||||
|         Get the value for the given key. | ||||
|         :param key: Key to get the value for | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key, | ||||
|             _KeyValueStoreModel.value_type == ValueTypesEnum.STRING).first() | ||||
|         if kv is None: | ||||
|             return None | ||||
|         return kv.string_value | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_datetime_value(key: KeyStoreKeys) -> Optional[datetime]: | ||||
|         """ | ||||
|         Get the value for the given key. | ||||
|         :param key: Key to get the value for | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key, | ||||
|             _KeyValueStoreModel.value_type == ValueTypesEnum.DATETIME).first() | ||||
|         if kv is None or kv.datetime_value is None: | ||||
|             return None | ||||
|         return kv.datetime_value.replace(tzinfo=timezone.utc) | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_float_value(key: KeyStoreKeys) -> Optional[float]: | ||||
|         """ | ||||
|         Get the value for the given key. | ||||
|         :param key: Key to get the value for | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key, | ||||
|             _KeyValueStoreModel.value_type == ValueTypesEnum.FLOAT).first() | ||||
|         if kv is None: | ||||
|             return None | ||||
|         return kv.float_value | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_int_value(key: KeyStoreKeys) -> Optional[int]: | ||||
|         """ | ||||
|         Get the value for the given key. | ||||
|         :param key: Key to get the value for | ||||
|         """ | ||||
|         kv = _KeyValueStoreModel.session.query(_KeyValueStoreModel).filter( | ||||
|             _KeyValueStoreModel.key == key, | ||||
|             _KeyValueStoreModel.value_type == ValueTypesEnum.INT).first() | ||||
|         if kv is None: | ||||
|             return None | ||||
|         return kv.int_value | ||||
|  | ||||
|  | ||||
| def set_startup_time(): | ||||
|     """ | ||||
|     sets bot_start_time to the first trade open date - or "now" on new databases. | ||||
|     sets startup_time to "now" | ||||
|     """ | ||||
|     st = KeyValueStore.get_value('bot_start_time') | ||||
|     if st is None: | ||||
|         from freqtrade.persistence import Trade | ||||
|         t = Trade.session.query(Trade).order_by(Trade.open_date.asc()).first() | ||||
|         if t is not None: | ||||
|             KeyValueStore.store_value('bot_start_time', t.open_date_utc) | ||||
|         else: | ||||
|             KeyValueStore.store_value('bot_start_time', datetime.now(timezone.utc)) | ||||
|     KeyValueStore.store_value('startup_time', datetime.now(timezone.utc)) | ||||
| @@ -13,6 +13,7 @@ from sqlalchemy.pool import StaticPool | ||||
|  | ||||
| from freqtrade.exceptions import OperationalException | ||||
| from freqtrade.persistence.base import ModelBase | ||||
| from freqtrade.persistence.key_value_store import _KeyValueStoreModel | ||||
| from freqtrade.persistence.migrations import check_migrate | ||||
| from freqtrade.persistence.pairlock import PairLock | ||||
| from freqtrade.persistence.trade_model import Order, Trade | ||||
| @@ -76,6 +77,7 @@ def init_db(db_url: str) -> None: | ||||
|         bind=engine, autoflush=False), scopefunc=get_request_or_thread_id) | ||||
|     Order.session = Trade.session | ||||
|     PairLock.session = Trade.session | ||||
|     _KeyValueStoreModel.session = Trade.session | ||||
|  | ||||
|     previous_tables = inspect(engine).get_table_names() | ||||
|     ModelBase.metadata.create_all(engine) | ||||
|   | ||||
| @@ -108,6 +108,8 @@ class Profit(BaseModel): | ||||
|     max_drawdown: float | ||||
|     max_drawdown_abs: float | ||||
|     trading_volume: Optional[float] | ||||
|     bot_start_timestamp: int | ||||
|     bot_start_date: str | ||||
|  | ||||
|  | ||||
| class SellReason(BaseModel): | ||||
|   | ||||
| @@ -26,7 +26,7 @@ from freqtrade.exceptions import ExchangeError, PricingError | ||||
| from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs | ||||
| from freqtrade.loggers import bufferHandler | ||||
| from freqtrade.misc import decimals_per_coin, shorten_date | ||||
| from freqtrade.persistence import Order, PairLocks, Trade | ||||
| from freqtrade.persistence import KeyStoreKeys, KeyValueStore, Order, PairLocks, Trade | ||||
| from freqtrade.persistence.models import PairLock | ||||
| from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist | ||||
| from freqtrade.rpc.fiat_convert import CryptoToFiatConverter | ||||
| @@ -543,6 +543,7 @@ class RPC: | ||||
|         first_date = trades[0].open_date if trades else None | ||||
|         last_date = trades[-1].open_date if trades else None | ||||
|         num = float(len(durations) or 1) | ||||
|         bot_start = KeyValueStore.get_datetime_value(KeyStoreKeys.BOT_START_TIME) | ||||
|         return { | ||||
|             'profit_closed_coin': profit_closed_coin_sum, | ||||
|             'profit_closed_percent_mean': round(profit_closed_ratio_mean * 100, 2), | ||||
| @@ -576,6 +577,8 @@ class RPC: | ||||
|             'max_drawdown': max_drawdown, | ||||
|             'max_drawdown_abs': max_drawdown_abs, | ||||
|             'trading_volume': trading_volume, | ||||
|             'bot_start_timestamp': int(bot_start.timestamp() * 1000) if bot_start else 0, | ||||
|             'bot_start_date': bot_start.strftime(DATETIME_PRINT_FORMAT) if bot_start else '', | ||||
|         } | ||||
|  | ||||
|     def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict: | ||||
|   | ||||
| @@ -819,7 +819,7 @@ class Telegram(RPCHandler): | ||||
|         best_pair = stats['best_pair'] | ||||
|         best_pair_profit_ratio = stats['best_pair_profit_ratio'] | ||||
|         if stats['trade_count'] == 0: | ||||
|             markdown_msg = 'No trades yet.' | ||||
|             markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`" | ||||
|         else: | ||||
|             # Message to display | ||||
|             if stats['closed_trade_count'] > 0: | ||||
| @@ -838,6 +838,7 @@ class Telegram(RPCHandler): | ||||
|                 f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" | ||||
|                 f"∙ `{round_coin_value(profit_all_fiat, fiat_disp_cur)}`\n" | ||||
|                 f"*Total Trade Count:* `{trade_count}`\n" | ||||
|                 f"*Bot started:* `{stats['bot_start_date']}`\n" | ||||
|                 f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " | ||||
|                 f"`{first_trade_date}`\n" | ||||
|                 f"*Latest Trade opened:* `{latest_trade_date}`\n" | ||||
|   | ||||
							
								
								
									
										69
									
								
								tests/persistence/test_key_value_store.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								tests/persistence/test_key_value_store.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| from datetime import datetime, timedelta, timezone | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| from freqtrade.persistence.key_value_store import KeyValueStore, set_startup_time | ||||
| from tests.conftest import create_mock_trades_usdt | ||||
|  | ||||
|  | ||||
| @pytest.mark.usefixtures("init_persistence") | ||||
| def test_key_value_store(time_machine): | ||||
|     start = datetime(2023, 1, 1, 4, tzinfo=timezone.utc) | ||||
|     time_machine.move_to(start, tick=False) | ||||
|  | ||||
|     KeyValueStore.store_value("test", "testStringValue") | ||||
|     KeyValueStore.store_value("test_dt", datetime.now(timezone.utc)) | ||||
|     KeyValueStore.store_value("test_float", 22.51) | ||||
|     KeyValueStore.store_value("test_int", 15) | ||||
|  | ||||
|     assert KeyValueStore.get_value("test") == "testStringValue" | ||||
|     assert KeyValueStore.get_value("test") == "testStringValue" | ||||
|     assert KeyValueStore.get_string_value("test") == "testStringValue" | ||||
|     assert KeyValueStore.get_value("test_dt") == datetime.now(timezone.utc) | ||||
|     assert KeyValueStore.get_datetime_value("test_dt") == datetime.now(timezone.utc) | ||||
|     assert KeyValueStore.get_string_value("test_dt") is None | ||||
|     assert KeyValueStore.get_float_value("test_dt") is None | ||||
|     assert KeyValueStore.get_int_value("test_dt") is None | ||||
|     assert KeyValueStore.get_value("test_float") == 22.51 | ||||
|     assert KeyValueStore.get_float_value("test_float") == 22.51 | ||||
|     assert KeyValueStore.get_value("test_int") == 15 | ||||
|     assert KeyValueStore.get_int_value("test_int") == 15 | ||||
|     assert KeyValueStore.get_datetime_value("test_int") is None | ||||
|  | ||||
|     time_machine.move_to(start + timedelta(days=20, hours=5), tick=False) | ||||
|     assert KeyValueStore.get_value("test_dt") != datetime.now(timezone.utc) | ||||
|     assert KeyValueStore.get_value("test_dt") == start | ||||
|     # Test update works | ||||
|     KeyValueStore.store_value("test_dt", datetime.now(timezone.utc)) | ||||
|     assert KeyValueStore.get_value("test_dt") == datetime.now(timezone.utc) | ||||
|  | ||||
|     KeyValueStore.store_value("test_float", 23.51) | ||||
|     assert KeyValueStore.get_value("test_float") == 23.51 | ||||
|     # test deleting | ||||
|     KeyValueStore.delete_value("test_float") | ||||
|     assert KeyValueStore.get_value("test_float") is None | ||||
|     # Delete same value again (should not fail) | ||||
|     KeyValueStore.delete_value("test_float") | ||||
|  | ||||
|     with pytest.raises(ValueError, match=r"Unknown value type"): | ||||
|         KeyValueStore.store_value("test_float", {'some': 'dict'}) | ||||
|  | ||||
|  | ||||
| @pytest.mark.usefixtures("init_persistence") | ||||
| def test_set_startup_time(fee, time_machine): | ||||
|     create_mock_trades_usdt(fee) | ||||
|     start = datetime.now(timezone.utc) | ||||
|     time_machine.move_to(start, tick=False) | ||||
|     set_startup_time() | ||||
|  | ||||
|     assert KeyValueStore.get_value("startup_time") == start | ||||
|     initial_time = KeyValueStore.get_value("bot_start_time") | ||||
|     assert initial_time <= start | ||||
|  | ||||
|     # Simulate bot restart | ||||
|     new_start = start + timedelta(days=5) | ||||
|     time_machine.move_to(new_start, tick=False) | ||||
|     set_startup_time() | ||||
|  | ||||
|     assert KeyValueStore.get_value("startup_time") == new_start | ||||
|     assert KeyValueStore.get_value("bot_start_time") == initial_time | ||||
| @@ -883,6 +883,8 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, is_short, expected) | ||||
|         'max_drawdown': ANY, | ||||
|         'max_drawdown_abs': ANY, | ||||
|         'trading_volume': expected['trading_volume'], | ||||
|         'bot_start_timestamp': 0, | ||||
|         'bot_start_date': '', | ||||
|     } | ||||
|  | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user