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