diff --git a/CHANGELOG.md b/CHANGELOG.md index ccda5d3..439b78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [0.4.0] - 2024-01-02 +### Added: +- Created `indicators` file, where I added `BolingerBands`, `RSI`, `PSAR`, `SMA` indicators +- Added `SharpeRatio` and `MaxDrawdown` metrics to `metrics` +- Included indicators handling into `data_feeder.PdDataFeeder` object +- Included indicators handling into `state.State` object + +### Changed: +- Changed `finrock` package dependency from `0.0.4` to `0.4.1` +- Refactored `render.PygameRender` object to handle indicators rendering (getting very messy) +- Updated `scalers.MinMaxScaler` to handle indicators scaling +- Updated `trading_env.TradingEnv` to raise an error with `np.nan` data and skip `None` states + + ## [0.3.0] - 2023-12-05 ### Added: - Added `DifferentActions` and `AccountValue` as metrics. Metrics are the main way to evaluate the performance of the agent. diff --git a/README.md b/README.md index ed7f58c..7aa0087 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Reinforcement Learning package for Finance # Environment Structure:

- +

### Install requirements: @@ -30,10 +30,21 @@ experiments/testing_ppo_sinusoid.py ### Environment Render:

- +

## Links to YouTube videos: - [Introduction to FinRock package](https://youtu.be/xU_YJB7vilA) - [Complete Trading Simulation Backbone](https://youtu.be/1z5geob8Yho) -- [Training RL agent on Sinusoid data](https://youtu.be/JkA4BuYvWyE) \ No newline at end of file +- [Training RL agent on Sinusoid data](https://youtu.be/JkA4BuYvWyE) +- [Included metrics and indicators into environment](https://youtu.be/bGpBEnKzIdo) + +# TODO: +- [ ] Train model on `continuous` actions (control allocation percentage) +- [ ] Add more indicators +- [ ] Add more metrics +- [ ] Add more reward functions +- [ ] Add more scalers +- [ ] Train RL agent on real data +- [ ] Add more RL algorithms +- [ ] Refactor rendering, maybe move to browser? \ No newline at end of file diff --git a/Tutorials/04_Indicators_and_Metrics.md b/Tutorials/04_Indicators_and_Metrics.md new file mode 100644 index 0000000..b1180de --- /dev/null +++ b/Tutorials/04_Indicators_and_Metrics.md @@ -0,0 +1,43 @@ +# Complete Trading Simulation Backbone + +### Environment Structure: +

+ +

+ +### Link to YouTube video: +https://youtu.be/bGpBEnKzIdo + +### Link to tutorial code: +https://github.com/pythonlessons/FinRock/tree/0.4.0 + +### Download tutorial code: +https://github.com/pythonlessons/FinRock/archive/refs/tags/0.4.0.zip + + +### Install requirements: +``` +pip install -r requirements.txt +pip install pygame +pip install . +``` + +### Create sinusoid data: +``` +python bin/create_sinusoid_data.py +``` + +### Train RL (PPO) agent on discrete actions: +``` +experiments/training_ppo_sinusoid.py +``` + +### Test trained agent (Change path to the saved model): +``` +experiments/testing_ppo_sinusoid.py +``` + +### Environment Render: +

+ +

\ No newline at end of file diff --git a/Tutorials/Documents/04_FinRock.jpg b/Tutorials/Documents/04_FinRock.jpg new file mode 100644 index 0000000..094f174 Binary files /dev/null and b/Tutorials/Documents/04_FinRock.jpg differ diff --git a/Tutorials/Documents/04_FinRock_render.png b/Tutorials/Documents/04_FinRock_render.png new file mode 100644 index 0000000..8972ea3 Binary files /dev/null and b/Tutorials/Documents/04_FinRock_render.png differ diff --git a/experiments/playing_random_sinusoid.py b/experiments/playing_random_sinusoid.py index 5b5fac5..2a6c67d 100644 --- a/experiments/playing_random_sinusoid.py +++ b/experiments/playing_random_sinusoid.py @@ -6,11 +6,21 @@ from finrock.render import PygameRender from finrock.scalers import MinMaxScaler from finrock.reward import simpleReward +from finrock.indicators import BolingerBands, SMA, RSI, PSAR df = pd.read_csv('Datasets/random_sinusoid.csv') -pd_data_feeder = PdDataFeeder(df) - +pd_data_feeder = PdDataFeeder( + df = df, + indicators = [ + BolingerBands(data=df, period=20, std=2), + RSI(data=df, period=14), + PSAR(data=df), + SMA(data=df, period=7), + SMA(data=df, period=25), + SMA(data=df, period=99), + ] +) env = TradingEnv( data_feeder = pd_data_feeder, @@ -21,10 +31,10 @@ reward_function = simpleReward ) action_space = env.action_space +input_shape = env.observation_space.shape pygameRender = PygameRender(frame_rate=60) - state, info = env.reset() pygameRender.render(info) rewards = 0.0 diff --git a/experiments/testing_ppo_sinusoid.py b/experiments/testing_ppo_sinusoid.py index 388ce22..e1e9f1b 100644 --- a/experiments/testing_ppo_sinusoid.py +++ b/experiments/testing_ppo_sinusoid.py @@ -10,13 +10,24 @@ from finrock.render import PygameRender from finrock.scalers import MinMaxScaler from finrock.reward import simpleReward -from finrock.metrics import DifferentActions, AccountValue +from finrock.metrics import DifferentActions, AccountValue, MaxDrawdown, SharpeRatio +from finrock.indicators import BolingerBands, RSI, PSAR, SMA df = pd.read_csv('Datasets/random_sinusoid.csv') df = df[-1000:] -pd_data_feeder = PdDataFeeder(df) +pd_data_feeder = PdDataFeeder( + df, + indicators = [ + BolingerBands(data=df, period=20, std=2), + RSI(data=df, period=14), + PSAR(data=df), + SMA(data=df, period=7), + SMA(data=df, period=25), + SMA(data=df, period=99), + ] + ) env = TradingEnv( data_feeder = pd_data_feeder, @@ -28,6 +39,8 @@ metrics = [ DifferentActions(), AccountValue(), + MaxDrawdown(), + SharpeRatio(), ] ) @@ -35,7 +48,7 @@ input_shape = env.observation_space.shape pygameRender = PygameRender(frame_rate=120) -agent = tf.keras.models.load_model('runs/1701698276/ppo_sinusoid_actor.h5') +agent = tf.keras.models.load_model('runs/1702982487/ppo_sinusoid_actor.h5') state, info = env.reset() pygameRender.render(info) @@ -51,7 +64,9 @@ pygameRender.render(info) if terminated or truncated: - print(rewards, info["metrics"]['account_value']) + print(rewards) + for metric, value in info['metrics'].items(): + print(metric, value) state, info = env.reset() rewards = 0.0 pygameRender.reset() diff --git a/experiments/training_ppo_sinusoid.py b/experiments/training_ppo_sinusoid.py index f9d84db..d973754 100644 --- a/experiments/training_ppo_sinusoid.py +++ b/experiments/training_ppo_sinusoid.py @@ -11,7 +11,8 @@ from finrock.trading_env import TradingEnv from finrock.scalers import MinMaxScaler from finrock.reward import simpleReward -from finrock.metrics import DifferentActions, AccountValue +from finrock.metrics import DifferentActions, AccountValue, MaxDrawdown, SharpeRatio +from finrock.indicators import BolingerBands, RSI, PSAR, SMA from rockrl.utils.misc import MeanAverage from rockrl.utils.memory import Memory @@ -20,8 +21,17 @@ df = pd.read_csv('Datasets/random_sinusoid.csv') df = df[:-1000] # leave 1000 for testing -pd_data_feeder = PdDataFeeder(df) - +pd_data_feeder = PdDataFeeder( + df, + indicators = [ + BolingerBands(data=df, period=20, std=2), + RSI(data=df, period=14), + PSAR(data=df), + SMA(data=df, period=7), + SMA(data=df, period=25), + SMA(data=df, period=99), + ] +) env = TradingEnv( data_feeder = pd_data_feeder, @@ -33,6 +43,8 @@ metrics = [ DifferentActions(), AccountValue(), + MaxDrawdown(), + SharpeRatio(), ] ) @@ -63,7 +75,7 @@ agent = PPOAgent( actor = actor_model, critic = critic_model, - optimizer=tf.keras.optimizers.Adam(learning_rate=0.0002), + optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001), batch_size=512, lamda=0.95, kl_coeff=0.5, @@ -71,7 +83,6 @@ writer_comment='ppo_sinusoid', ) - memory = Memory() meanAverage = MeanAverage(best_mean_score_episode=1000) state, info = env.reset() @@ -98,6 +109,5 @@ memory.reset() state, info = env.reset() - if agent.epoch >= 10000: break \ No newline at end of file diff --git a/finrock/__init__.py b/finrock/__init__.py index f2b3589..49c37a8 100644 --- a/finrock/__init__.py +++ b/finrock/__init__.py @@ -1 +1 @@ -__version__ = "0.3.0" \ No newline at end of file +__version__ = "0.4.0" \ No newline at end of file diff --git a/finrock/data_feeder.py b/finrock/data_feeder.py index 932c2aa..fa3c287 100644 --- a/finrock/data_feeder.py +++ b/finrock/data_feeder.py @@ -1,17 +1,21 @@ import pandas as pd - from finrock.state import State +from finrock.indicators import Indicator + class PdDataFeeder: def __init__( self, df: pd.DataFrame, + indicators: list = [], min: float = None, max: float = None ) -> None: self._df = df self._min = min self._max = max + self._indicators = indicators + self._cache = {} assert isinstance(self._df, pd.DataFrame) == True, "df must be a pandas.DataFrame" assert 'timestamp' in self._df.columns, "df must have 'timestamp' column" @@ -20,6 +24,9 @@ def __init__( assert 'low' in self._df.columns, "df must have 'low' column" assert 'close' in self._df.columns, "df must have 'close' column" + assert isinstance(self._indicators, list) == True, "indicators must be an iterable" + assert all(isinstance(indicator, Indicator) for indicator in self._indicators) == True, "indicators must be a list of Indicator objects" + @property def min(self) -> float: return self._min or self._df['low'].min() @@ -32,16 +39,30 @@ def __len__(self) -> int: return len(self._df) def __getitem__(self, idx: int, args=None) -> State: - data = self._df.iloc[idx] + # Use cache to speed up training + if idx in self._cache: + return self._cache[idx] + + indicators = [] + for indicator in self._indicators: + results = indicator(idx) + if results is None: + self._cache[idx] = None + return None + + indicators.append(results) + data = self._df.iloc[idx] state = State( timestamp=data['timestamp'], open=data['open'], high=data['high'], low=data['low'], close=data['close'], - volume=data.get('volume', 0.0) + volume=data.get('volume', 0.0), + indicators=indicators ) + self._cache[idx] = state return state diff --git a/finrock/indicators.py b/finrock/indicators.py new file mode 100644 index 0000000..a96f67d --- /dev/null +++ b/finrock/indicators.py @@ -0,0 +1,363 @@ +import pandas as pd + +from .render import RenderOptions, RenderType, WindowType + + +class Indicator: + """ Base class for indicators + """ + def __init__( + self, + data: pd.DataFrame, + target_column: str='close', + render_options: dict={} + ) -> None: + self._data = data.copy() + self._target_column = target_column + self._render_options = render_options + self.values = {} + + assert isinstance(self._data, pd.DataFrame) == True, "data must be a pandas.DataFrame" + assert self._target_column in self._data.columns, f"data must have '{self._target_column}' column" + + self.compute() + if not self._render_options: + self._render_options = self.default_render_options() + + @property + def min(self): + return self._data[self.target_column].min() + + @property + def max(self): + return self._data[self.target_column].max() + + @property + def target_column(self): + return self._target_column + + @property + def name(self): + return self.__class__.__name__ + + @property + def names(self): + return self._names + + def compute(self): + raise NotImplementedError + + def default_render_options(self): + return {} + + def render_options(self): + return {name: option.copy() for name, option in self._render_options.items()} + + def __getitem__(self, index: int): + row = self._data.iloc[index] + for name in self.names: + if pd.isna(row[name]): + return None + + self.values[name] = row[name] + if self._render_options.get(name): + self._render_options[name].value = row[name] + + return self.serialise() + + def __call__(self, index: int): + return self[index] + + def serialise(self): + return { + 'name': self.name, + 'names': self.names, + 'values': self.values.copy(), + 'target_column': self.target_column, + 'render_options': self.render_options(), + 'min': self.min, + 'max': self.max + } + + +class SMA(Indicator): + """ Trend indicator + + A simple moving average (SMA) calculates the average of a selected range of prices, usually closing prices, by the number + of periods in that range. + + The SMA is a technical indicator for determining if an asset price will continue or reverse a bull or bear trend. It is + calculated by summing up the closing prices of a stock over time and then dividing that total by the number of time periods + being examined. Short-term averages respond quickly to changes in the price of the underlying, while long-term averages are + slow to react. + + https://www.investopedia.com/terms/s/sma.asp + """ + def __init__( + self, + data: pd.DataFrame, + period: int=20, + target_column: str='close', + render_options: dict={} + ): + self._period = period + self._names = [f'SMA{period}'] + super().__init__(data, target_column, render_options) + + @property + def min(self): + return self._data[self.names[0]].min() + + @property + def max(self): + return self._data[self.names[0]].max() + + def default_render_options(self): + return {name: RenderOptions( + name=name, + color=(100, 100, 255), + window_type=WindowType.MAIN, + render_type=RenderType.LINE, + min=self.min, + max=self.max + ) for name in self._names} + + def compute(self): + self._data[self.names[0]] = self._data[self.target_column].rolling(self._period).mean() + + +class BolingerBands(Indicator): + """ Volatility indicator + + Bollinger Bands are a type of price envelope developed by John BollingerOpens in a new window. (Price envelopes define + upper and lower price range levels.) Bollinger Bands are envelopes plotted at a standard deviation level above and + below a simple moving average of the price. Because the distance of the bands is based on standard deviation, they + adjust to volatility swings in the underlying price. + + Bollinger Bands use 2 parameters, Period and Standard Deviations, StdDev. The default values are 20 for period, and 2 + for standard deviations, although you may customize the combinations. + + Bollinger bands help determine whether prices are high or low on a relative basis. They are used in pairs, both upper + and lower bands and in conjunction with a moving average. Further, the pair of bands is not intended to be used on its own. + Use the pair to confirm signals given with other indicators. + """ + def __init__( + self, + data: pd.DataFrame, + period: int=20, + std: int=2, + target_column: str='close', + render_options: dict={} + ): + self._period = period + self._std = std + self._names = ['SMA', 'BB_up', 'BB_dn'] + super().__init__(data, target_column, render_options) + + @property + def min(self): + return self._data['BB_dn'].min() + + @property + def max(self): + return self._data['BB_up'].max() + + def compute(self): + self._data['SMA'] = self._data[self.target_column].rolling(self._period).mean() + self._data['BB_up'] = self._data['SMA'] + self._data[self.target_column].rolling(self._period).std() * self._std + self._data['BB_dn'] = self._data['SMA'] - self._data[self.target_column].rolling(self._period).std() * self._std + + def default_render_options(self): + return {name: RenderOptions( + name=name, + color=(100, 100, 255), + window_type=WindowType.MAIN, + render_type=RenderType.LINE, + min=self.min, + max=self.max + ) for name in self._names} + + +class RSI(Indicator): + """ Momentum indicator + + The Relative Strength Index (RSI), developed by J. Welles Wilder, is a momentum oscillator that measures the speed and + change of price movements. The RSI oscillates between zero and 100. Traditionally the RSI is considered overbought when + above 70 and oversold when below 30. Signals can be generated by looking for divergences and failure swings. + RSI can also be used to identify the general trend. + """ + def __init__( + self, + data: pd.DataFrame, + period: int=14, + target_column: str='close', + render_options: dict={} + ): + self._period = period + self._names = ['RSI'] + super().__init__(data, target_column, render_options) + + @property + def min(self): + return 0.0 + + @property + def max(self): + return 100.0 + + def compute(self): + delta = self._data[self.target_column].diff() + up = delta.clip(lower=0) + down = -1 * delta.clip(upper=0) + ema_up = up.ewm(com=self._period-1, adjust=True, min_periods=self._period).mean() + ema_down = down.ewm(com=self._period-1, adjust=True, min_periods=self._period).mean() + rs = ema_up / ema_down + self._data['RSI'] = 100 - (100 / (1 + rs)) + + def default_render_options(self): + custom_options = { + "RSI0": 0, + "RSI30": 30, + "RSI70": 70, + "RSI100": 100 + } + options = {name: RenderOptions( + name=name, + color=(100, 100, 255), + window_type=WindowType.SEPERATE, + render_type=RenderType.LINE, + min=self.min, + max=self.max + ) for name in self._names} + + for name, value in custom_options.items(): + options[name] = RenderOptions( + name=name, + color=(192, 192, 192), + window_type=WindowType.SEPERATE, + render_type=RenderType.LINE, + min=self.min, + max=self.max, + value=value + ) + return options + + +class PSAR(Indicator): + """ Parabolic Stop and Reverse (Parabolic SAR) + + The Parabolic Stop and Reverse, more commonly known as the + Parabolic SAR,is a trend-following indicator developed by + J. Welles Wilder. The Parabolic SAR is displayed as a single + parabolic line (or dots) underneath the price bars in an uptrend, + and above the price bars in a downtrend. + + https://school.stockcharts.com/doku.php?id=technical_indicators:parabolic_sar + """ + def __init__( + self, + data: pd.DataFrame, + step: float=0.02, + max_step: float=0.2, + target_column: str='close', + render_options: dict={} + ): + self._names = ['PSAR'] + self._step = step + self._max_step = max_step + super().__init__(data, target_column, render_options) + + @property + def min(self): + return self._data['PSAR'].min() + + @property + def max(self): + return self._data['PSAR'].max() + + def default_render_options(self): + return {name: RenderOptions( + name=name, + color=(100, 100, 255), + window_type=WindowType.MAIN, + render_type=RenderType.DOT, + min=self.min, + max=self.max + ) for name in self._names} + + def compute(self): + high = self._data['high'] + low = self._data['low'] + close = self._data[self.target_column] + + up_trend = True + acceleration_factor = self._step + up_trend_high = high.iloc[0] + down_trend_low = low.iloc[0] + + self._psar = close.copy() + self._psar_up = pd.Series(index=self._psar.index, dtype="float64") + self._psar_down = pd.Series(index=self._psar.index, dtype="float64") + + for i in range(2, len(close)): + reversal = False + + max_high = high.iloc[i] + min_low = low.iloc[i] + + if up_trend: + self._psar.iloc[i] = self._psar.iloc[i - 1] + ( + acceleration_factor * (up_trend_high - self._psar.iloc[i - 1]) + ) + + if min_low < self._psar.iloc[i]: + reversal = True + self._psar.iloc[i] = up_trend_high + down_trend_low = min_low + acceleration_factor = self._step + else: + if max_high > up_trend_high: + up_trend_high = max_high + acceleration_factor = min( + acceleration_factor + self._step, self._max_step + ) + + low1 = low.iloc[i - 1] + low2 = low.iloc[i - 2] + if low2 < self._psar.iloc[i]: + self._psar.iloc[i] = low2 + elif low1 < self._psar.iloc[i]: + self._psar.iloc[i] = low1 + else: + self._psar.iloc[i] = self._psar.iloc[i - 1] - ( + acceleration_factor * (self._psar.iloc[i - 1] - down_trend_low) + ) + + if max_high > self._psar.iloc[i]: + reversal = True + self._psar.iloc[i] = down_trend_low + up_trend_high = max_high + acceleration_factor = self._step + else: + if min_low < down_trend_low: + down_trend_low = min_low + acceleration_factor = min( + acceleration_factor + self._step, self._max_step + ) + + high1 = high.iloc[i - 1] + high2 = high.iloc[i - 2] + if high2 > self._psar.iloc[i]: + self._psar[i] = high2 + elif high1 > self._psar.iloc[i]: + self._psar.iloc[i] = high1 + + up_trend = up_trend != reversal # XOR + + if up_trend: + self._psar_up.iloc[i] = self._psar.iloc[i] + else: + self._psar_down.iloc[i] = self._psar.iloc[i] + + # calculate psar indicator + self._data['PSAR'] = self._psar \ No newline at end of file diff --git a/finrock/metrics.py b/finrock/metrics.py index 4e03b49..63408ea 100644 --- a/finrock/metrics.py +++ b/finrock/metrics.py @@ -1,11 +1,12 @@ from .state import State +import numpy as np """ Metrics are used to track and log information about the environment. possible metrics: -- DifferentActions, -- AccountValue, -- MaxDrawdown, -- SharpeRatio, ++ DifferentActions, ++ AccountValue, ++ MaxDrawdown, ++ SharpeRatio, - AverageProfit, - AverageLoss, - AverageTrade, @@ -80,4 +81,80 @@ def result(self): def reset(self, prev_state: State=None): super().reset(prev_state) - self.account_value = prev_state.account_value if prev_state else 0.0 \ No newline at end of file + self.account_value = prev_state.account_value if prev_state else 0.0 + + +class MaxDrawdown(Metric): + """ The Maximum Drawdown (MDD) is a measure of the largest peak-to-trough decline in the + value of a portfolio or investment during a specific period + + The Maximum Drawdown Ratio represents the proportion of the peak value that was lost during + the largest decline. It is a measure of the risk associated with a particular investment or + portfolio. Investors and fund managers use the Maximum Drawdown and its ratio to assess the + historical downside risk and potential losses that could be incurred. + """ + def __init__(self, name: str="max_drawdown") -> None: + super().__init__(name=name) + + def update(self, state: State): + super().update(state) + + # Use min to find the trough value + self.max_account_value = max(self.max_account_value, state.account_value) + + # Calculate drawdown + drawdown = (state.account_value - self.max_account_value) / self.max_account_value + + # Update max drawdown if the current drawdown is greater + self.max_drawdown = min(self.max_drawdown, drawdown) + + @property + def result(self): + return self.max_drawdown + + def reset(self, prev_state: State=None): + super().reset(prev_state) + + self.max_account_value = prev_state.account_value if prev_state else 0.0 + self.max_drawdown = 0.0 + + +class SharpeRatio(Metric): + """ The Sharpe Ratio, is a measure of the risk-adjusted performance of an investment or a portfolio. + It helps investors evaluate the return of an investment relative to its risk. + + A higher Sharpe Ratio indicates a better risk-adjusted performance. Investors and portfolio managers + often use the Sharpe Ratio to compare the risk-adjusted returns of different investments or portfolios. + It allows them to assess whether the additional return earned by taking on additional risk is justified. + """ + def __init__(self, ratio_days=365.25, name: str='sharpe_ratio'): + self.ratio_days = ratio_days + super().__init__(name=name) + + def update(self, state: State): + super().update(state) + time_difference_days = (state.date - self.prev_state.date).days + if time_difference_days >= 1: + self.daily_returns.append((state.account_value - self.prev_state.account_value) / self.prev_state.account_value) + self.account_values.append(state.account_value) + self.prev_state = state + + @property + def result(self): + if len(self.daily_returns) == 0: + return 0.0 + + mean = np.mean(self.daily_returns) + std = np.std(self.daily_returns) + if std == 0: + return 0.0 + + sharpe_ratio = mean / std * np.sqrt(self.ratio_days) + + return sharpe_ratio + + def reset(self, prev_state: State=None): + super().reset(prev_state) + self.prev_state = prev_state + self.account_values = [] + self.daily_returns = [] \ No newline at end of file diff --git a/finrock/render.py b/finrock/render.py index fdab228..4200372 100644 --- a/finrock/render.py +++ b/finrock/render.py @@ -1,5 +1,44 @@ +from enum import Enum from .state import State +class RenderType(Enum): + LINE = 0 + DOT = 1 + +class WindowType(Enum): + MAIN = 0 + SEPERATE = 1 + +class RenderOptions: + def __init__( + self, + name: str, + color: tuple, + window_type: WindowType, + render_type: RenderType, + min: float, + max: float, + value: float = None, + ): + self.name = name + self.color = color + self.window_type = window_type + self.render_type = render_type + self.min = min + self.max = max + self.value = value + + def copy(self): + return RenderOptions( + name=self.name, + color=self.color, + window_type=self.window_type, + render_type=self.render_type, + min=self.min, + max=self.max, + value=self.value + ) + class ColorTheme: black = (0, 0, 0) white = (255, 255, 255) @@ -15,7 +54,70 @@ class ColorTheme: buy = green sell = red font = 'Noto Sans' - font_size = 20 + font_ratio = 0.02 + +class MainWindow: + def __init__( + self, + width: int, + height: int, + top_offset: int, + bottom_offset: int, + window_size: int, + candle_spacing, + font_ratio: float=0.02, + spacing_ratio: float=0.02, + split_offset: int=0 + ): + self.width = width + self.height = height + self.top_offset = top_offset + self.bottom_offset = bottom_offset + self.window_size = window_size + self.candle_spacing = candle_spacing + self.font_ratio = font_ratio + self.spacing_ratio = spacing_ratio + self.split_offset = split_offset + + self.seperate_window_ratio = 0.15 + + @property + def font_size(self): + return int(self.height * self.font_ratio) + + @property + def candle_width(self): + return self.width // self.window_size - self.candle_spacing + + @property + def chart_height(self): + return self.height - (2 * self.top_offset + self.bottom_offset) + + @property + def spacing(self): + return int(self.height * self.spacing_ratio) + + @property + def screen_shape(self): + return (self.width, self.height) + + @screen_shape.setter + def screen_shape(self, value: tuple): + self.width, self.height = value + + def map_price_to_window(self, price: float, max_low: float, max_high: float): + max_range = max_high - max_low + height = self.chart_height - self.split_offset - self.bottom_offset - self.top_offset * 2 + value = int(height - (price - max_low) / max_range * height) + self.top_offset + return value + + def map_to_seperate_window(self, value: float, min: float, max: float): + self.split_offset = int(self.height * self.seperate_window_ratio) + max_range = max - min + new_value = int(self.split_offset - (value - min) / max_range * self.split_offset) + height = self.chart_height - self.split_offset + new_value + return height + class PygameRender: def __init__( @@ -23,25 +125,33 @@ def __init__( window_size: int=100, screen_width: int=1440, screen_height: int=1080, - top_bottom_offset: int=25, + top_offset: int=25, + bottom_offset: int=25, candle_spacing: int=1, color_theme = ColorTheme(), frame_rate: int=30, render_balance: bool=True, ): - # pygame window settings self.screen_width = screen_width self.screen_height = screen_height - self.top_bottom_offset = top_bottom_offset + self.top_offset = top_offset + self.bottom_offset = bottom_offset self.candle_spacing = candle_spacing self.window_size = window_size self.color_theme = color_theme self.frame_rate = frame_rate self.render_balance = render_balance - self.candle_width = self.screen_width // self.window_size - self.candle_spacing - self.chart_height = self.screen_height - 2 * self.top_bottom_offset + self.mainWindow = MainWindow( + width=self.screen_width, + height=self.screen_height, + top_offset=self.top_offset, + bottom_offset=self.bottom_offset, + window_size=self.window_size, + candle_spacing=self.candle_spacing, + font_ratio=self.color_theme.font_ratio + ) self._states = [] @@ -53,17 +163,11 @@ def __init__( self.pygame.init() self.pygame.display.init() - self.screen_shape = (self.screen_width, self.screen_height) - self.window = self.pygame.display.set_mode(self.screen_shape, self.pygame.RESIZABLE) + self.window = self.pygame.display.set_mode(self.mainWindow.screen_shape, self.pygame.RESIZABLE) self.clock = self.pygame.time.Clock() def reset(self): self._states = [] - - def _map_price_to_window(self, price, max_low, max_high): - max_range = max_high - max_low - value = int(self.chart_height - (price - max_low) / max_range * self.chart_height) + self.top_bottom_offset - return value def _prerender(func): """ Decorator for input data validation and pygame window rendering""" @@ -79,7 +183,7 @@ def wrapper(self, info: dict, rgb_array: bool=False): return if event.type == self.pygame.VIDEORESIZE: - self.screen_shape = (event.w, event.h) + self.mainWindow.screen_shape = (event.w, event.h) # pause if spacebar is pressed if event.type == self.pygame.KEYDOWN: @@ -95,12 +199,11 @@ def wrapper(self, info: dict, rgb_array: bool=False): self.pygame.quit() return - self.screen_shape = self.pygame.display.get_surface().get_size() + self.mainWindow.screen_shape = self.pygame.display.get_surface().get_size() - # self.screen.fill(self.color_theme.background) canvas = func(self, info) - canvas = self.pygame.transform.scale(canvas, self.screen_shape) + canvas = self.pygame.transform.scale(canvas, self.mainWindow.screen_shape) # The following line copies our drawings from `canvas` to the visible window self.window.blit(canvas, canvas.get_rect()) self.pygame.display.update() @@ -110,11 +213,109 @@ def wrapper(self, info: dict, rgb_array: bool=False): return self.pygame.surfarray.array3d(canvas) return wrapper + + def render_indicators(self, state: State, canvas: object, candle_offset: int, max_low: float, max_high: float): + # connect last 2 points with a line + for i, indicator in enumerate(state.indicators): + for name, render_option in indicator["render_options"].items(): + + index = self._states.index(state) + if not index: + return + last_state = self._states[index - 1] + + if render_option.render_type == RenderType.LINE: + prev_render_option = last_state.indicators[i]["render_options"][name] + if render_option.window_type == WindowType.MAIN: + + cur_value_map = self.mainWindow.map_price_to_window(render_option.value, max_low, max_high) + prev_value_map = self.mainWindow.map_price_to_window(prev_render_option.value, max_low, max_high) + + elif render_option.window_type == WindowType.SEPERATE: + + cur_value_map = self.mainWindow.map_to_seperate_window(render_option.value, render_option.min, render_option.max) + prev_value_map = self.mainWindow.map_to_seperate_window(prev_render_option.value, prev_render_option.min, prev_render_option.max) + + self.pygame.draw.line(canvas, render_option.color, + (candle_offset - self.mainWindow.candle_width / 2, prev_value_map), + (candle_offset + self.mainWindow.candle_width / 2, cur_value_map)) + + elif render_option.render_type == RenderType.DOT: + if render_option.window_type == WindowType.MAIN: + self.pygame.draw.circle(canvas, render_option.color, + (candle_offset, self.mainWindow.map_price_to_window(render_option.value, max_low, max_high)), 2) + elif render_option.window == WindowType.SEPERATE: + raise NotImplementedError('Seperate window for indicators is not implemented yet') + + def render_candle(self, state: State, canvas: object, candle_offset: int, max_low: float, max_high: float, font: object): + assert isinstance(state, State) == True # check if state is a State object + + # Calculate candle coordinates + candle_y_open = self.mainWindow.map_price_to_window(state.open, max_low, max_high) + candle_y_close = self.mainWindow.map_price_to_window(state.close, max_low, max_high) + candle_y_high = self.mainWindow.map_price_to_window(state.high, max_low, max_high) + candle_y_low = self.mainWindow.map_price_to_window(state.low, max_low, max_high) + + # Determine candle color + if state.open < state.close: + # up candle + candle_color = self.color_theme.up_candle + candle_body_y = candle_y_close + candle_body_height = candle_y_open - candle_y_close + else: + # down candle + candle_color = self.color_theme.down_candle + candle_body_y = candle_y_open + candle_body_height = candle_y_close - candle_y_open + + # Draw candlestick wicks + self.pygame.draw.line(canvas, self.color_theme.wick, + (candle_offset + self.mainWindow.candle_width // 2, candle_y_high), + (candle_offset + self.mainWindow.candle_width // 2, candle_y_low)) + + # Draw candlestick body + self.pygame.draw.rect(canvas, candle_color, (candle_offset, candle_body_y, self.mainWindow.candle_width, candle_body_height)) + + # Compare with previous state to determine whether buy or sell action was taken and draw arrow + index = self._states.index(state) + if index > 0: + last_state = self._states[index - 1] + + if last_state.allocation_percentage < state.allocation_percentage: + # buy + candle_y_low = self.mainWindow.map_price_to_window(last_state.low, max_low, max_high) + self.pygame.draw.polygon(canvas, self.color_theme.buy, [ + (candle_offset - self.mainWindow.candle_width / 2, candle_y_low + self.mainWindow.spacing / 2), + (candle_offset - self.mainWindow.candle_width * 0.1, candle_y_low + self.mainWindow.spacing), + (candle_offset - self.mainWindow.candle_width * 0.9, candle_y_low + self.mainWindow.spacing) + ]) + + # add account_value label bellow candle + if self.render_balance: + text = str(int(last_state.account_value)) + buy_label = font.render(text, True, self.color_theme.text) + label_width, label_height = font.size(text) + canvas.blit(buy_label, (candle_offset - (self.mainWindow.candle_width + label_width) / 2, candle_y_low + self.mainWindow.spacing)) + + elif last_state.allocation_percentage > state.allocation_percentage: + # sell + candle_y_high = self.mainWindow.map_price_to_window(last_state.high, max_low, max_high) + self.pygame.draw.polygon(canvas, self.color_theme.sell, [ + (candle_offset - self.mainWindow.candle_width / 2, candle_y_high - self.mainWindow.spacing / 2), + (candle_offset - self.mainWindow.candle_width * 0.1, candle_y_high - self.mainWindow.spacing), + (candle_offset - self.mainWindow.candle_width * 0.9, candle_y_high - self.mainWindow.spacing) + ]) + + # add account_value label above candle + if self.render_balance: + text = str(int(last_state.account_value)) + sell_label = font.render(text, True, self.color_theme.text) + label_width, label_height = font.size(text) + canvas.blit(sell_label, (candle_offset - (self.mainWindow.candle_width + label_width) / 2, candle_y_high - self.mainWindow.spacing - label_height)) @_prerender def render(self, info: dict): - - canvas = self.pygame.Surface((self.screen_width , self.screen_height)) + canvas = self.pygame.Surface(self.mainWindow.screen_shape) canvas.fill(self.color_theme.background) max_high = max([state.high for state in self._states[-self.window_size:]]) @@ -123,80 +324,23 @@ def render(self, info: dict): candle_offset = self.candle_spacing # Set font for labels - font = self.pygame.font.SysFont(self.color_theme.font, self.color_theme.font_size) + font = self.pygame.font.SysFont(self.color_theme.font, self.mainWindow.font_size) for state in self._states[-self.window_size:]: - assert isinstance(state, State) == True # check if state is a State object - - # Calculate candle coordinates - candle_y_open = self._map_price_to_window(state.open, max_low, max_high) - candle_y_close = self._map_price_to_window(state.close, max_low, max_high) - candle_y_high = self._map_price_to_window(state.high, max_low, max_high) - candle_y_low = self._map_price_to_window(state.low, max_low, max_high) - - # Determine candle color - if state.open < state.close: - # up candle - candle_color = self.color_theme.up_candle - candle_body_y = candle_y_close - candle_body_height = candle_y_open - candle_y_close - else: - # down candle - candle_color = self.color_theme.down_candle - candle_body_y = candle_y_open - candle_body_height = candle_y_close - candle_y_open - - # Draw candlestick wicks - self.pygame.draw.line(canvas, self.color_theme.wick, (candle_offset + self.candle_width // 2, candle_y_high), (candle_offset + self.candle_width // 2, candle_y_low)) - - # Draw candlestick body - self.pygame.draw.rect(canvas, candle_color, (candle_offset, candle_body_y, self.candle_width, candle_body_height)) - - # Compare with previous state to determine whether buy or sell action was taken and draw arrow - index = self._states.index(state) - if index > 0: - last_state = self._states[index - 1] + # draw indicators + self.render_indicators(state, canvas, candle_offset, max_low, max_high) - if last_state.allocation_percentage < state.allocation_percentage: - # buy - candle_y_low = self._map_price_to_window(last_state.low, max_low, max_high) - self.pygame.draw.polygon(canvas, self.color_theme.buy, [ - (candle_offset - self.candle_width / 2, candle_y_low + 10), - (candle_offset - self.candle_width / 2 - 5, candle_y_low + 20), - (candle_offset - self.candle_width / 2 + 5, candle_y_low + 20) - ]) - - # add account_value label bellow candle - if self.render_balance: - text = str(int(last_state.account_value)) - buy_label = font.render(text, True, self.color_theme.text) - label_width, label_height = font.size(text) - canvas.blit(buy_label, (candle_offset - (self.candle_width + label_width) / 2, candle_y_low + 20)) - - elif last_state.allocation_percentage > state.allocation_percentage: - # sell - candle_y_high = self._map_price_to_window(last_state.high, max_low, max_high) - self.pygame.draw.polygon(canvas, self.color_theme.sell, [ - (candle_offset - self.candle_width / 2, candle_y_high - 10), - (candle_offset - self.candle_width / 2 - 5, candle_y_high - 20), - (candle_offset - self.candle_width / 2 + 5, candle_y_high - 20) - ]) - - # add account_value label above candle - if self.render_balance: - text = str(int(last_state.account_value)) - sell_label = font.render(text, True, self.color_theme.text) - label_width, label_height = font.size(text) - canvas.blit(sell_label, (candle_offset - (self.candle_width + label_width) / 2, candle_y_high - 20 - label_height)) + # draw candle + self.render_candle(state, canvas, candle_offset, max_low, max_high, font) # Move to the next candle - candle_offset += self.candle_width + self.candle_spacing + candle_offset += self.mainWindow.candle_width + self.candle_spacing # Draw max and min ohlc values on the chart label_width, label_height = font.size(str(max_low)) label_y_low = font.render(str(max_low), True, self.color_theme.text) - canvas.blit(label_y_low, (self.candle_spacing + 5, self.screen_height - label_height * 2)) + canvas.blit(label_y_low, (self.candle_spacing + 5, self.mainWindow.height - label_height * 2)) label_width, label_height = font.size(str(max_low)) label_y_high = font.render(str(max_high), True, self.color_theme.text) diff --git a/finrock/scalers.py b/finrock/scalers.py index b9c15fa..6c4c27e 100644 --- a/finrock/scalers.py +++ b/finrock/scalers.py @@ -12,12 +12,21 @@ def transform(self, observations: Observations) -> np.ndarray: transformed_data = [] for state in observations: - open = (state.open - self._min) / (self._max - self._min) - high = (state.high - self._min) / (self._max - self._min) - low = (state.low - self._min) / (self._max - self._min) - close = (state.close - self._min) / (self._max - self._min) + data = [] + for name in ['open', 'high', 'low', 'close']: + value = getattr(state, name) + transformed_value = (value - self._min) / (self._max - self._min) + data.append(transformed_value) - transformed_data.append([open, high, low, close, state.allocation_percentage]) + data.append(state.allocation_percentage) + + # append scaled indicators + for indicator in state.indicators: + for value in indicator["values"].values(): + transformed_value = (value - indicator["min"]) / (indicator["max"] - indicator["min"]) + data.append(transformed_value) + + transformed_data.append(data) return np.array(transformed_data) diff --git a/finrock/state.py b/finrock/state.py index d5fc153..94edcfa 100644 --- a/finrock/state.py +++ b/finrock/state.py @@ -9,7 +9,8 @@ def __init__( high: float, low: float, close: float, - volume: float=0.0 + volume: float=0.0, + indicators: list=[] ): self.timestamp = timestamp self.open = open @@ -17,6 +18,7 @@ def __init__( self.low = low self.close = close self.volume = volume + self.indicators = indicators try: self.date = datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S') @@ -100,7 +102,8 @@ def reset(self) -> None: self._observations = [] def append(self, state: State) -> None: - assert isinstance(state, State) == True, "state must be a State object" + # state should be State object or None + assert isinstance(state, State) or state is None, "state must be a State object or None" self._observations.append(state) if len(self._observations) > self._window_size: diff --git a/finrock/trading_env.py b/finrock/trading_env.py index 83c87e8..56c5647 100644 --- a/finrock/trading_env.py +++ b/finrock/trading_env.py @@ -35,6 +35,9 @@ def observation_space(self): def _get_obs(self, index: int, balance: float=None) -> State: next_state = self._data_feeder[index] + if next_state is None: + return None + if balance is not None: next_state.balance = balance @@ -108,6 +111,9 @@ def step(self, action: int) -> typing.Tuple[State, float, bool, bool, dict]: transformed_obs = self._output_transformer.transform(self._observations) + if np.isnan(transformed_obs).any(): + raise ValueError("transformed_obs contains nan values, check your data") + return transformed_obs, reward, terminated, truncated, info def reset(self) -> typing.Tuple[State, dict]: @@ -120,7 +126,11 @@ def reset(self) -> typing.Tuple[State, dict]: # Initial observations are the first states of the window size self._observations.reset() while not self._observations.full: - self._observations.append(self._get_obs(self._env_step_indexes.pop(0), balance=self._initial_balance)) + obs = self._get_obs(self._env_step_indexes.pop(0), balance=self._initial_balance) + if obs is None: + continue + # update observations object with new observation + self._observations.append(obs) info = { "states": self._observations.observations, @@ -132,7 +142,9 @@ def reset(self) -> typing.Tuple[State, dict]: metric.reset(self._observations.observations[-1]) transformed_obs = self._output_transformer.transform(self._observations) - + if np.isnan(transformed_obs).any(): + raise ValueError("transformed_obs contains nan values, check your data") + # return state and info return transformed_obs, info diff --git a/requirements.txt b/requirements.txt index 195bdf5..7f172e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ numpy pandas matplotlib -rockrl==0.0.4 -tensorflow==2.10.0 -tensorflow_probability==0.18.0 \ No newline at end of file +rockrl==0.4.1 +tensorflow==2.10.0 \ No newline at end of file