├── .gitignore ├── .idea └── .gitignore ├── CITATION.cff ├── DOCUMENTATION.md ├── README.md ├── Simulating.RMMS.-.Documentation.pdf ├── config.ini ├── error_distribution.py ├── error_distribution_arbitrage_frequency.py ├── modules ├── arb.py ├── cfmm.py ├── optimize_fee.py ├── simulate.py └── utils.py ├── optimal_fees_parallel.py ├── optimal_fees_visualization.py ├── simulation.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | sim_results 3 | optimization_results 4 | .idea/inspectionProfiles/profiles_settings.xml 5 | .idea/inspectionProfiles/Project_Default.xml 6 | .idea/misc.xml 7 | .idea/modules.xml 8 | .idea/other.xml 9 | .idea/rmms-py.iml 10 | .idea/vcs.xml 11 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Experience" 5 | given-names: "Experience" 6 | title: "rmms-py" 7 | version: 1.0.0 8 | date-released: 2022-04-23 9 | url: "https://github.com/primitivefinance/rmms-py" 10 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Simulating RMMS - Documentation 2 | 3 | ## Primer on CFMMs and arbitrage 4 | 5 | ### Trading functions 6 | 7 | Constant functions market makers (CFMMs)for two assets are functions of the reserves of each asset in the pool of the form $\varphi(x, y)$ that define a trading rule. In this case, $x$ and $y$ designate the reserves of each asset. The trading rule is such that if a trader adds some amount of tokens in $\Delta$, they will get an amount out $\Delta'$ such that: 8 | 9 | $$ \varphi(x, y) = \varphi(x + \Delta, y + \Delta')$$ 10 | 11 | where $\Delta'$ is negative to express the fact that it's an amount out. Hence the name constant function. In the case of Uniswap v2 for example, the function is: 12 | 13 | $$\varphi(x, y) = x \cdot y$$ 14 | 15 | This is often written as: 16 | 17 | $$x \cdot y = k$$ 18 | 19 | where $k$ is called the "invariant", so valid trades are those that will keep the product of the reserves after the trade equal to that invariant. Sometimes, a fee is charged to the trader. The way it works is that the calculation of the amount out is done with only a fraction of the amount in, which means the trader receives a lower amount out, but in the end the entire amount in is actually added to the pool. 20 | 21 | Let's illustrate this again in the case of Uniswap. Let's say there is some fee $f \in [0, 1]$. Define $\gamma = 1 - f$. We start with a state of the liquidity pool $(x, y)$ where the invariant is initially $k = x \cdot y$. In the present document, $x$ will always designate some "risky asset" and $y$ some "riskless asset". The trader requests a trade with some amount in $\Delta$. To find the amount out $\Delta'$, we solve the following equation: 22 | 23 | $$(x + \gamma \Delta)(y - \Delta') = k$$ 24 | 25 | The reserves are then updated to $(x', y') = (x + \Delta)(y - \Delta')$. Since we found a $\Delta'$ with a $\gamma$ factor, obviously the product of the new reserves is not equal to $k$ anymore, and so there is a new "invariant" $k'$ that will be used for the next trade. Since the trader is getting a lower amount out with the same amount in compared to the no-fee case, we know that this new invariant will be greater, and it can be shown to always be the case for any quasiconcave, strictly increasing function. So we must have the relation: 26 | 27 | $$ \forall \ \varphi, \ k' > k \ $$ 28 | 29 | For more details on this, see [Angeris and Chitra (2020)](https://arxiv.org/abs/2003.10001). 30 | 31 | ### Reported price, marginal price 32 | 33 | This section is dedicated to understanding the price the traders get when they request a trade against a CFMM and the logic behind arbitrage, in the simple case of a two assets CFMM. 34 | 35 | First let's consider the case of a no-fee CFMM. This CFMM can be understood as having a "spot price", which is that price that the trader would get if they were to swap an infinitesimal amount of one of the tokens. When there are two tokens, the trading function can often be rewritten as $y = f(x)$. If the trader adds some infinitesimal amount $dx$ of token $x$, the new amount of the other token must satisfy the new equation, so we have: 36 | 37 | $$y' = f(x') = f(x + dx)$$ 38 | 39 | Thus the trader was given some infinitesimal amount out $y' - y = dy$ (defined as negative because this amount is getting out of the pool). If we want to know the marginal price of that trade denominated in unit riskless per risky, we must compute the ratio: 40 | 41 | $$-\frac{dy}{dx} = \frac{y' - y}{x' - x}$$ 42 | 43 | where there is a negative sign because the price must be positive. We can rewrite this as: 44 | 45 | $$- \frac{f(x + dx) - f(x)}{dx}$$ 46 | 47 | Which when $dx \rightarrow 0$ is the definition of $-f'(x)$, the opposite of the derivative of $f$ at $x$. So the "spot price" of a no-fee CFMM at some given reserves is the slope of the tangent at those reserves. 48 | 49 | In the case of Uniswap, we see that the price denominated in riskless per risky goes up as the reserves of the risky get exhausted, and conversely, as expected. 50 | 51 | ![Uniswap v2 CFMM illustration](https://i.imgur.com/2pkK82Y.png) 52 | *Figure 1. An illustration of the Uniswap v2 CFMM from [this blog post](https://rossbulat.medium.com/uniswap-v2-everything-new-with-the-decentralised-exchange-52b4bb2093ab).* 53 | 54 | This spot price is also sometimes called the *reported price* of the CFMM. That reported price can also be found in the following way: 55 | 56 | The marginal price of an infinitesimal trade when swapping one of the assets can be found in the following way. 57 | 58 | For any amount in of the risky asset $\Delta$, we can find some amount out $\Delta'$ (taken positive here for simplicit). We can thus express the amount out as a function of the amount in $\Delta'(\Delta)$. Now we would like to know, given that a trader requests to swap some amount in $\Delta$, what would be the marginal price of adding an infinitesimal amount $d\Delta$ to that request? The difference in the amount the trader would be $\Delta'(\Delta + d\Delta) - \Delta'(\Delta)$, while the difference in the amount in is $d\Delta$. So the price of that small infinitesimal trade is: 59 | 60 | $$g(\Delta) = \frac{\Delta'(\Delta + d\Delta) - \Delta'(\Delta)}{d\Delta} = \frac{d\Delta'}{d\Delta}$$ 61 | 62 | or the derivative of the function $\Delta'(\Delta)$. Thus the marginal price of an infinitesimal trade is $g(0)$, which can be shown to be equal to $f'(x)$ as defined above. 63 | 64 | No actual trade will have exactly that price, the marginal price of a non infinitesimal trade is of course $\Delta'/\Delta$. 65 | 66 | $g(\Delta)$ represents what the marginal price of an infinitesimal trade *would be* after a trade of size $\Delta$. The same argument must be mirrored to reason about swapping in the other asset. 67 | 68 | When the CFMM has fees, the above argument doesn't work anymore. The marginal price of an infinitesimal trade is not the same depending on whether we're swapping in the risky or the riskless asset. This can be understood as a "spread", similar to an orderbook. This is due to the $\gamma$ factor discussed above which is applied to the amount in, and so there is a dependence on which asset we're swapping. If the CFMM is asymmetrical in its arguments (i.e. $x$ and $y$ can't be interchanged without changing the value of the trading function), the spread will also be asymmetrical. 69 | 70 | It can be shown that in the case when there are fees, the marginal price of a an infinitesimal trade after a trade of size $\Delta$ is given by $\gamma g(\gamma \Delta)$ where $g$ is the function previously defined in the no-fee case. The marginal price of swapping some infinitesimal amount is thus given by $\gamma g(0)$. Since the function $g$ is not necessarily the same depending on which asset we're swapping, in particular when the function is asymmetric, **the price of buying and selling the risky asset in the pool denominated in riskless per risky is *not* necessarily the same.** 71 | 72 | ### Optimal arbitrage 73 | 74 | An arbitrager seeks to exploit a difference between the price on the reference market and the marginal price in the pool. Assuming an infinitely liquid reference market with price $m$, the goal of the optimal arbitrage problem is to swap as much amount in such that after the swap, the marginal price is equal to the reference price. This can be expressed neatly as: 75 | 76 | $$\gamma g(\gamma \Delta^{*}) = m$$ 77 | 78 | This is the equation we have to solve for $\Delta^{*}$, the optimal amount in. Depending on whether the reference price $m$ is above the price of buying, or selling the risky asset in the pool, the function $g$ might be different as discussed above. If the reference price is between the price of buying and the price of selling the risky asset in the pool, there is no profitable arbitrage possible. 79 | 80 | ## Theory of the Covered Call CFMM 81 | 82 | Here we are specifically looking at the covered call CFMM defined in the [RMMS paper](https://arxiv.org/abs/2003.10001). That CFMM is: 83 | 84 | $$ y - K \Phi(\Phi^{-1}(1 - x) - \sigma \sqrt{\tau}) = k$$ 85 | 86 | where the invariant $k = 0$ in the zero fees case. It can be shown indeed that assuming no-arbitrage, the value of the reserves of that CFMM (and hence the value of the LP shares) tracks the value of a covered call of a given time to maturity, strike price, and implied volatility, as illustrated below. 87 | 88 | ![Covered call CFMM payoff illustration](https://i.imgur.com/vT5LACb.png) 89 | 90 | *Figure 2. The value of the LP shares in the covered call CFMM, assuming no-arbitrage, as a function of the risky asset price and $\tau$.* 91 | 92 | For every $\gamma, \ \tau, \ k$, we can calculate the different quantities of interest for that CFMM, with and without fees: 93 | 94 | ### Reported price 95 | 96 | We write the CFMM as: 97 | 98 | $$y = f(x) = K \Phi(\Phi^{-1}(1 - x) - \sigma \sqrt{\tau}) + k$$ 99 | 100 | Taking the derivative gives us the reported price $S$ as a function of the risky asset reserves. Using the chain rule: 101 | 102 | $$S(x) = K\phi(\Phi^{-1}(1 - x) - \sigma \sqrt{\tau}) \times (\Phi^{-1})'(1 - x)$$ 103 | 104 | Where $\phi$ is the derivative of the standard CDF $\Phi$, the standard normal PDF. Using the inverse function theorem, we can get an analytical expression for the derivative of the inverse CDF: 105 | 106 | $$\forall x \in ]0, 1[, \ (\Phi^{-1})'(x) = \frac{1}{\phi(\Phi^{-1}(x))}$$ 107 | 108 | Such that we can rewrite: 109 | 110 | $$S(x) = K\frac{\phi(\Phi^{-1}(1 - x) - \sigma \sqrt{\tau})}{\phi(\Phi^{-1}(1 - x))}$$ 111 | 112 | Using the definition of $\phi(x) = \frac{1}{\sqrt{2\pi}} e^{-\frac{x^{2}}{2}}$, we can further simplify by noting that: 113 | 114 | $$ \phi(\Phi^{-1}(1 - x) - \sigma \sqrt{\tau}) = \phi(\Phi^{-1}(1-x)) e^{2\Phi^{-1}(x)\sigma \sqrt{\tau}} e^{-\sigma^{2}\tau}$$ 115 | 116 | And in the end: 117 | 118 | $$S(x) = \frac{K}{2\pi}e^{2\Phi^{-1}(1 - x)\sigma \sqrt{\tau}} e^{-\sigma^{2}\tau}$$ 119 | 120 | We can see that all prices are supported by this AMM for any value of $\sigma$ or $\tau$. Indeed, $\displaystyle \lim_{x \to 0} S(x) = +\infty$ and $\displaystyle \lim_{x \to 1} S(x) = 0$. However, because of the presence of the standard normal quantile function, also known as the probit function, there are significant kinks at the boundaries as seen in the plots below: 121 | 122 | ![Reported price plot](https://i.imgur.com/Mv8AuY8.png) 123 | 124 | ![Reported price plot edges](https://i.imgur.com/JmlQCFx.png) 125 | 126 | The effect of this is that while the AMM does theoretically support any price between $0$ and $+\infty$, some prices cannot practically be reached in a real world setting. For example for the value $\tau = 0.33$ in the figure above, a decrease of a factor of 1000 from risky reserves of 1e-13 ETH to 1e-16 ETH only moves the reported price up by 14% to 3978 USD per ETH. This means that above some threshold, the pools will behave as if they can be emptied above a certain price threshold, at which point the CFMM will not report any meaningful price other than a lower / upper bound. 127 | 128 | ### Swap risky in 129 | 130 | Let's assume that a trader requests a swap of an amount $\Delta$ of the risky asset in a pool that initially has reserves $x, \ y$. The new amount of risky assets in the reserves is $x' = x + \Delta$. Assuming a fee regime $\gamma$, we can obtain the new riskless reserves as follows: 131 | 132 | $$y' = K \Phi(\Phi^{-1}(1 - (x + \gamma \Delta)) - \sigma \sqrt{\tau}) + k$$ 133 | 134 | And so the amount out to give to the traders is $\Delta' = y - y'$. 135 | 136 | Of course $k$ needs to be updated according to the new amounts in the pool as: 137 | 138 | $$k = y' - K \Phi(\Phi^{-1}(1 - x') - \sigma \sqrt{\tau})$$ 139 | 140 | ### Swap riskless in 141 | 142 | Similarly, if a trader requests a swap of an amount $\Delta$ of the riskless asset, the new reserves of the riskless asset are $y' = y + \Delta$. The relationship between $x'$ and $y'$ is, again: 143 | 144 | $$y' = K \Phi(\Phi^{-1}(1 - x') - \sigma \sqrt{\tau}) + k$$ 145 | 146 | We can inverse that relationship with some algebra and successive application of the inverse CDF and the CDF on both sides: 147 | 148 | $$x' = 1 - \Phi\left(\Phi^{-1}\left(\frac{y + \gamma \Delta - k}{K}\right) + \sigma \sqrt{\tau}\right)$$ 149 | 150 | Such that the amount out to give to the trader is $\Delta' = x - x'$, and $k$ needs to be appropriately updated as above. 151 | 152 | ### Price of buying the risky asset 153 | 154 | In the no fees case, we can obtain the price of buying the risky, ie swapping some amount in $\Delta$ of the riskless asset into the pool, using the procedure described in the previous section. The invariant equation for the trade: 155 | 156 | $$y + \Delta - K \Phi(\Phi^{-1}(1 - (x - \Delta)) - \sigma \sqrt{\tau}) = k$$ 157 | 158 | We can then write the amount out $\Delta'$ as a function of the amount in $\Delta$: 159 | 160 | $$\Delta' = x - 1 + \Phi\left(\Phi^{-1}\left(\frac{y + \Delta - k}{K}\right) + \sigma \sqrt{\tau}\right)$$ 161 | 162 | And we obtain: 163 | 164 | $$\frac{d\Delta'}{d\Delta} = g(\Delta) = \frac{1}{K}\phi\left(\Phi^{-1}\left(\frac{y + \Delta - k}{K}\right) + \sigma \sqrt{\tau}\right) \times (\Phi^{-1})'\left( \frac{y + \Delta - k}{K}\right)$$ 165 | 166 | With a fee regime of $\gamma$, that price becomes $\gamma g(\gamma \Delta)$. 167 | 168 | ### Price of selling the risky asset 169 | 170 | A similar procedure gives us when swapping some amount in $\Delta$ of the risky asset: 171 | 172 | $$\frac{d\Delta'}{d\Delta} = g(\Delta) = K \phi(\Phi^{-1}(1 - x - \Delta) - \sigma \sqrt{\tau}) \times (\Phi^{-1})'(1 - x - \Delta)$$ 173 | 174 | ## Fee optimization 175 | 176 | In the case of the Covered Call CFMM, replication fails because of the increase in value of the instrument as we get closer to expiry. It was conjectured in the RMMS paper that implementing a fee might allow to recover the tau decay. 177 | 178 | Below is the evolution of the payoff in the zero fee case with a 24 hours arbitrage period. 179 | 180 | ![Payoff evolution no fees](https://i.imgur.com/xzfk6QN.png) 181 | 182 | If we increase the fee to 5% in the same market conditions, we will get closer to the theoretical payoff as seen below: 183 | 184 | ![Payoff evolution 5% fees](https://i.imgur.com/993UQAF.png) 185 | 186 | However, if we increase the fees too much, we will start going lower again because the price will spend too much time in the no arbitrage bound (which are made large because of the large fee), as seen below with an exaggerated fee of 20%. 187 | 188 | ![Payoff evolutions 20% fees](https://i.imgur.com/Hbc3vsD.png) 189 | 190 | In this case, we're only looking at a single price path. One might ask, given some parameters for the pool and some assumed market conditions, what is the fee that minimizes the error between the effective and theoretical LP values at the time of expiry? To do this, we need to generate a large number of price paths and average over them to get an expected error for each static fee chosen, and then run an optimization routine. 191 | 192 | Of note is that the result should be highly dependent on arbitrage frequency. Indeed, if arbitrage happens more often, that means that more fees are accrued to the pool. 193 | 194 | Below is a plot showing the log-normal distribution of errors given different static fees fitted from the data of 150 different paths. 195 | 196 | ![Errors distributions](https://i.imgur.com/vVsHNYE.png) 197 | 198 | We can see that as expected, the distributions are more skewed towards as the fee increases (i.e. $\gamma$ decreases) until a certain point where the distribution seems to go back to higher errors (see deeper colored curve). Of note is also that above some threshold, the fitted distributions become hard to distinguish. 199 | 200 | For each choice of time horizon, arbitrage frequency, it is possible to construct a mapping of (volatility, drift, strike price) to an optimal static fee that minimizes the expected error. The result of such an optimal fee search is given in the figure below: 201 | 202 | ![Optimal fee](https://i.imgur.com/dg4PToC.png) 203 | 204 | Unfortunately, this result is not quite as smooth as we might have expected. This is likely because, as discussed previously, it would appear that for a given choice of parameter, there is a *range* of static fees that produces a very similar error distribution. To resolve this problem and have a smoother mapping, one might significantly increase the number of paths used in the objetive function to determine the average error, such that the average of the runs would be very close to the actual mean and/or significantly decrease the tolerance search. Further analysis might be required to assess the rate of convergence of the mean of a sample to the "true" mean. 205 | 206 | ## Effect of arbitrage frequency 207 | 208 | The more frequently arbitrage is assumed to occur, the lower the average error given a fixed fee, and thus the lower the optial fee should be. This is demonstrated in the figures below where the distribution of error and mean errors are plotted for different arbitrage frequencies given a fixed fee of 1% in the same market conditions. 209 | 210 | ![Error distribution arbitrage frequency](https://i.imgur.com/A4XrGWp.png) 211 | 212 | ![Mean error arbitrage frequency](https://i.imgur.com/uKspEAx.png) 213 | 214 | ## Implementation 215 | 216 | ### ``utils.py``: Utility functions used throughout 217 | 218 | Contains simple utility functions such as the GBM generation algorithm, the derivative of the quantile function, or functions that allow to calculate reserves. 219 | 220 | ### ``cfmm.py``: CFMM pool implementation 221 | 222 | The AMM pool is an object whose attributes are the reserves as well as all the other parameters that appear in the covered call trading function. The formulas outlined above are implemented using the ``scipy.stats.norm`` implementation of the normal distribution and related functions. To get the reserves that the AMM should be updated to (and thus deduce an amount out) given an amount in, the equations described in the theory section above are used. Whenever a price is returned by a function, it is denominated in riskless per risky. 223 | 224 | ```python 225 | class CoveredCallAMM(): 226 | ''' 227 | A class to represent a two-tokens AMM with the covered call trading function. 228 | 229 | Attributes 230 | ___________ 231 | 232 | reserves_risky: float 233 | the reserves of the AMM pool in the risky asset 234 | reserves_riskless: float 235 | the reserves of the AMM pool in the riskless asset 236 | tau: float 237 | the time to maturity for this pool in the desired units 238 | K: float 239 | the strike price for this pool 240 | sigma: float 241 | the volatility for this pool, scaled to be consistent with the unit of tau (annualized if tau is in years etc) 242 | invariant: float 243 | the invariant of the CFMM 244 | ''' 245 | 246 | def __init__(self, initial_x, K, sigma, tau, fee): 247 | ''' 248 | Initialize the AMM pool with a starting risky asset reserve as an 249 | input, calculate the corresponding riskless asset reserve needed to 250 | satisfy the trading function equation. 251 | ''' 252 | 253 | def getRisklessGivenRisky(self, risky): 254 | '''Get riskless reserves corresponding to the risky reserves with the current invariant''' 255 | 256 | def getRisklessGivenRiskyNoInvariant(self, risky): 257 | '''Get risky reserves corresponding to the current riskless reserves assuming an invariant of 0''' 258 | 259 | def getRiskyGivenRiskless(self, riskless): 260 | '''Get riskless reserves corresponding to the riskless reserves with the current invariant''' 261 | 262 | def swapAmountInRisky(self, amount_in): 263 | ''' 264 | Swap in some amount of the risky asset and get some amount of the riskless asset in return. 265 | 266 | Returns: 267 | 268 | amount_out: the amount to be given out to the trader 269 | effective_price_in_risky: the effective price of the executed trade 270 | ''' 271 | 272 | def virtualSwapAmountInRisky(self, amount_in): 273 | ''' 274 | Perform a swap and then revert the state of the pool. 275 | 276 | Returns: 277 | 278 | amount_out: the amount that the trader would get out given the amount in 279 | effective_price_in_riskless: the effective price the trader would pay for that trade denominated in the riskless asset 280 | ''' 281 | 282 | def swapAmountInRiskless(self, amount_in): 283 | ''' 284 | Swap in some amount of the riskless asset and get some amount of the 285 | risky asset in return. 286 | 287 | Returns: 288 | 289 | amount_out: the amount to be given to the trader 290 | effective_price_in_riskless: the effective price the trader actually paid for that trade denominated in the riskless asset 291 | ''' 292 | 293 | def virtualSwapAmountInRiskless(self, amount_in): 294 | ''' 295 | Perform a swap and then revert the state of the pool. 296 | 297 | Returns: 298 | 299 | amount_out: the amount that the trader would get out given the amount in 300 | effective_price_in_riskless: the effective price the trader would pay for that trade denominated in the riskless asset 301 | ''' 302 | 303 | 304 | def getSpotPrice(self): 305 | ''' 306 | Get the current spot price (ie "reported price" using CFMM jargon) of 307 | the risky asset, denominated in the riskless asset, only exact in the 308 | no-fee case. 309 | ''' 310 | 311 | def getMarginalPriceSwapRiskyIn(self, amount_in): 312 | ''' 313 | Returns the marginal price after a trade of size amount_in (in the 314 | risky asset) with the current reserves (in RISKLESS.RISKY-1). 315 | See https://arxiv.org/pdf/2012.08040.pdf 316 | ''' 317 | 318 | def getMarginalPriceSwapRisklessIn(self, amount_in): 319 | ''' 320 | Returns the marginal price after a trade of size amount_in (in the 321 | riskless asset) with the current reserves (in RISKLESS.RISKY-1) 322 | See https://arxiv.org/pdf/2012.08040.pdf 323 | ''' 324 | 325 | def getRiskyReservesGivenSpotPrice(self, S): 326 | ''' 327 | Given some spot price S in the no-fee case, get the risky reserves corresponding to that 328 | spot price by solving the S = -y' = -f'(x) for x. 329 | ''' 330 | ``` 331 | 332 | ### Arbitrager ``arb.py`` 333 | 334 | This file provides a *function* that given a market price and a pool, acts on the pool to perform the exact trade that will bring the pool price in line with the reference market price. 335 | 336 | ```python 337 | def arbitrageExactly(market_price, Pool): 338 | ''' 339 | Arbitrage the difference *exactly* at the time of the call to the function. Uses results from the following paper: https://arxiv.org/abs/2012.08040 340 | 341 | Params: 342 | 343 | reference_price (float): 344 | the reference price of the risky asset, denominated in the riskless asset 345 | Pool (AMM object): 346 | an AMM object, for example a CoveredCallAMM class, with some current state and reserves 347 | ''' 348 | ``` 349 | 350 | In this section, $\varepsilon$ is always equal to $10^{-8}$. 351 | 352 | First we run a couple of checks from line 40 to 54. The intent here is to check whether the pools are almost empty or almost full to a precision of $\varepsilon$. If they are, the arbitrager does not perform any action in order to avoid having to deal with the kinks [previously described](https://hackmd.io/ogN_4dhNTdqwhe_VyKas8Q?both#Reported-price). 353 | 354 | The arbitrager first checks the marginal price of an $\varepsilon$ swap of each asset. If the price differs from some reference price, they will try to find an optimal trade using the method described above. 355 | 356 | In particular, if the price of selling the risky asset is above the market price to a precision of $\varepsilon$ (line 58), the arbitrager looks for the amount of the risky asset that they should sell between $\varepsilon$ and $1 - R_{1} - \varepsilon$ (the maximum amount of the risky assets that we can add in so as to not) to bring the prices back in line, where $R_{1}$ are the risky reserves. 357 | 358 | If the price buying the risky asset is below the market price to a precision of $\varepsilon$ (line 75), the arbitrager looks for the amount of the riskless asset that they should swap in between $\varepsilon$ and $\frac{K + k - R_{2}}{\gamma}$ to bring the prices back in line where $R_{2}$ are the riskless reserves. 359 | 360 | ``scipy.opitmize.brentq`` is used to solve the root finding problem. The choice of this method is motivated by a benchmark that determined it is the fastest converging for this particular problem. After the problem is solved, the arbitrager checks that they are making a profit and if yes, executes the trade (lines 70 to 72 and 86 to 88). 361 | 362 | **Note on the bounds of the search:** 363 | 364 | With a negative invariant, the maximum amount of both the riskless and the risky goes down compared to K and 1 respectively because we're translating the curve downward by the value of the invariant. 365 | 366 | In the case of looking for an amount of risky asset to swap in, all of the functions are still perfectly defined even when requesting a trade with an amount of risky in greater than those bounds and up to 1, it will just return a negative value of the riskless reserves. To take this into account, the `virtualSwapAmountInRisky` function checks whether the new reserves risky would be negative, and if yes returns 0 for the amount out to give to the trader. This makes sure that when checking for profit, the arbitrager always finds a negative profit and does not actually perform the trade. 367 | 368 | In the case of looking for an amount of riskless asset to swap in, the `cfmm.getMarginalPriceSwapRisklessIn()` and `cfmm.getRiskyGivenRiskless()` are not defined for all values requested. In particular, the amount of riskless in appears as an argument in the following formulas. 369 | 370 | ```python 371 | norm.ppf((riskless - self.invariant)/self.K) 372 | 373 | quantilePrime((R + gamma*amount_in - k)/K) 374 | ``` 375 | 376 | This is the reason why the amount of riskless in may never be greater than $\frac{K + k - R_{2}}{\gamma}$ as specified as the bounds for the root-finding algorithm, otherwise the `quantilePrime` function would not be defined. 377 | 378 | ### Simulation routine ``simulate.py`` 379 | 380 | The function `simulate()` takes in a Pool, a time array, and a corresponding GBM and runs the optimal arbitrage simulation under these conditions. 381 | 382 | Every time step, this is what happens in the simulation: 383 | 384 | 1. Update the pool's $\tau$. 385 | 2. Update the invariant $k$ to reflect the change in $\tau$ (otherwise we get in the interior of the trading set and value can be extracted from the pool for free). 386 | 3. Run `arbitrageExactly()` on the pool. If a profitable optimal arbitrage is found, a swap occurs, which changes the reserves, and the invariant 387 | 4. Move to the next time step. 388 | 389 | At every timestep, the value of the reserves in the pool *after arbitrage* and the theoretical desired payoff are recorded. At the end of the simulation, these are compared. The goal is for them to be as close as possible. 390 | 391 | The simulation currently doesn't take into account any gas fee. 392 | 393 | ### Fee optimization module `optimize_fee.py` 394 | 395 | The `returnErrors()` function is simply a recasting of the `simulate()` function such that it returns only the mean error and terminal error given a number of parameters. It is used to define the objective function in the fee optimization routine. 396 | 397 | `findOptimalFee()` is the actual fee optimization routine. It takes in all the parameters required to run a simulation except the fee regime, and returns the optimal fee for that regime. Within this routine, we define the objective function `ErrorFromFee()` which given a fee, runs a number of simulations with the given parameters and returns the average terminal error. These runs are parallelized using the `joblib` library as seen on line 50. To specify the number of paths one would like to average over, one should simply change the range of the for loop within the `Parallel` function call to the desired number of paths. 398 | 399 | The optimal fee is found using the `scipy.optimize.fminbound` method. We look for an optimal fee between 0 and 10%. 400 | 401 | ### Fee optimization script `optimal_fees_parallel.py` and data visualization `optimal_fee_visualization.py` 402 | 403 | The above module is used in `optimal_fees_parallel.py` to construct a mapping of optimal fees such as the one discussed in the previous section. The user specifies a range of volatility, drift and an initial price as a fraction of the strike price. We then loop over all parameters to run `findOptimalFee()` with all of these parameters. The results are then recorded as an array in the JSON format in the `optimization_results` folder. 404 | 405 | To visualize the results as a 2D color mapping, one needs to first go in `optimal_fee_visualization.py` and retrieve the `parameters` and `optimal_fees` arrays from the JSON. `x` is the range of volatility parameters and `y` the range of initial prices parameters. `drift_index` specifies the index of the drift value we want to plot the mapping for. The mapping is then plotted using Matplotlib's `imshow()`. 406 | 407 | ## Observations regarding the behavior of the pool 408 | 409 | ### Observation 1: negative invariant 410 | 411 | Given a pool with set parameters and an initial invariant of 0, if $\tau$ is decreased while everything else remains equal and before any arbitrage occurs, the invariant will take on a negative value. 412 | 413 | Explanation: as we update tau without changing the reserves, the invariant needs to be changed to keep the two sides of the equation equal. Graphically, decreasing tau would bring us to a higher curve, but because we haven't changed the reserves we're not anymore in the reachable reserve set. Decreasing the value of the invariant translates the curve down so that we are again in the reachable reserve set. 414 | 415 | ### Observation 2: negative reserves 416 | 417 | Similar to Observation 2, we start with a given fee-less pool, update $\tau$, and then update $k$ accordingly. We observe that on this new curve, it's some reported prices are associated with negative reserves of the riskless asset, which are practically unreachable. These are sometimes encountered in the simulations, especially for smaller values of $\tau$ where reasonable prices can get us exceedingly close to the right side of the curve. Because there is a negative $k$, which has the effect of translating the curve down, it may cross the x-axis. This means that some of the prices on the curve become practically inaccessible (reserves emptied at a higher price). 418 | 419 | This is demonstrated in the test script on line 187 at commit ``a6afd46`` in the branch ``use-config-file``. Below is a visual representation of this for parameters that were encountered in a simulation run. 420 | 421 | ### Observation 3: fees don't always fill the theta gap 422 | 423 | Even when choosing the optimal fee returned by the optimization routine, a static fee might still lead to a relatively large error for some price paths. 424 | 425 | ![Fees theta gap](https://i.imgur.com/j1bnvJ5.png) 426 | 427 | ## Appendix: Root finding benchmarking 428 | 429 | Consider the following pieces of code at commit `a43263c` in `rmms-py` for terminal error comparison and runtime benchmarking: 430 | 431 | Terminal error comparison: 432 | 433 | ```python 434 | import numpy as np 435 | import time 436 | import time_series 437 | import cfmm 438 | from simulate import simulate 439 | fee = 0.05 440 | strike = 2000 441 | initial_price = 0.8*2000 442 | volatility = 0.5 443 | drift = 0.5 444 | time_steps_size = 0.0027397260274 445 | time_horizon = 1 446 | initial_tau = 1 447 | total_time = 0 448 | np.random.seed(300) 449 | Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 450 | t, gbm = time_series.generateGBM(time_horizon, drift, volatility, initial_price, time_steps_size) 451 | start = time.time() 452 | _, _, _, d = simulate(Pool, t, gbm) 453 | end = time.time() 454 | print("RUNTIME: ", end-start) 455 | print(d) 456 | ``` 457 | 458 | Runtime benchmarking: 459 | 460 | ```python 461 | import time 462 | import time_series 463 | import cfmm 464 | from simulate import simulate 465 | fee = 0.01 466 | strike = 2000 467 | initial_price = 0.8*2000 468 | volatility = 0.5 469 | drift = 0.5 470 | time_steps_size = 0.0027397260274 471 | time_horizon = 1 472 | initial_tau = 1 473 | total_time = 0 474 | Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 475 | for i in range(100): 476 | t, gbm = time_series.generateGBM(time_horizon, drift, volatility, initial_price, time_steps_size) 477 | start = time.time() 478 | _, _, _, _ = simulate(Pool, t, gbm) 479 | end = time.time() 480 | total_time += end-start 481 | print("Average runtime: ", total_time/100) 482 | ``` 483 | 484 | Let us do a bunch of comparisons in terminal error and runtime benchmarking. 485 | 486 | |SEED |d bisect | d illinois | d brentq | d brenth | d ridder | d toms748 | 487 | |-----|--------|--------|--------|--------|--------|--------| 488 | |15425 | 0.040279 | 0.040281 |0.040280 |0.040280 |0.040280 |0.040280 | 489 | |100 | 0.014655 | 0.014079 |0.014079 |0.014079 |0.014079 |0.014079 | 490 | |200 | 0.001378 | 0.001378 |0.001378 |0.001378 |0.001378 |0.001378 | 491 | |300 | 0.008522 | 0.008521 |0.008522 |0.008522 |0.008522 |0.008522 | 492 | 493 | *Table 1. Terminal error for different seeds and root finding algorithms.* 494 | 495 | | Method |Average runtime (s) | 496 | |-----|--------| 497 | |bisect | 6.3 | 498 | |illinois | 0.95 | 499 | |brentq | 0.564 | 500 | |brenth | 0.577 | 501 | |ridder | 0.325 - 0.611 (*) | 502 | |toms478 | 0.567 | 503 | 504 | *Table 2. Average runtime for each method over 100 random price trajectories.* 505 | 506 | (*) average runtime over 100 price trajectories with the ridder method appears to be very variable for some reason. 507 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RMMS simulations 2 | 3 | This project is intended to investigate the replication of payoffs using custom Constant Function Market Makers (CFMMs) in the spirit of the 2021 paper from [Angeris, Evans and Chitra.](https://stanford.edu/~guillean/papers/rmms.pdf) For now it only focuses on the Covered Call replication. The project is organized as follows: 4 | 5 | ``modules`` contains all the simulation toolkit. In particular: 6 | 7 | - ``modules/arb.py`` implements the optimal arbitrage logic. 8 | - ``modules/cfmm.py`` implements the actual CFMM pool logic. 9 | - ``modules/utils.py`` contains a number of utility functions (math, geometric brownian motion generation). 10 | - ``modules/simulate.py`` is simply the function used to run an individual simulation. 11 | - ``modules/optimize_fee.py`` contains the logic required to find the optimal fee given some market and pool parameters. 12 | 13 | ``simulation.py`` is a script used to run individual simulations whose parameters are specified in the ``config.ini`` file. 14 | 15 | ``optimal_fees_parallel.py`` is a script to run an actual fee optimization routine for a prescribed parameter space (to be specified within the script itself). 16 | 17 | ``optimal_fees_visualization.py`` is a script that generates a visual representation of the output of a fee optimization routine. 18 | 19 | ``error_distribution.py`` is a script to plot the distribution of errors given some market and pool parameters for different fee regimes. 20 | 21 | All the different functions and design choices are documented in a separate document. 22 | 23 | ## Requirements 24 | 25 | ``pip install numpy, pip install scipy, pip install matplotlib, pip install joblib`` 26 | -------------------------------------------------------------------------------- /Simulating.RMMS.-.Documentation.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/primitivefinance/rmms-py/e25c6792c7cffe69ed7fcb8633419fd5007a1eed/Simulating.RMMS.-.Documentation.pdf -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [Pool parameters] 2 | STRIKE_PRICE = 3300 3 | #Time to maturity of the option the pool is 4 | #representing, in years 5 | TIME_TO_MATURITY = 1 6 | #Fee of the pool 7 | FEE = 0.02 8 | 9 | [Price action parameters] 10 | #Initial reference market price 11 | INITIAL_REFERENCE_PRICE = 2000 12 | #Annualized volatility 13 | ANNUALIZED_VOL = 0.8 14 | #Annual drift 15 | DRIFT = 1 16 | #The time horizon in years 17 | TIME_HORIZON = 1 18 | #The size of the time steps in years 19 | TIME_STEPS_SIZE = 0.002737851 20 | 21 | [Simulation parameters] 22 | #Timesteps multiples at which tau is 23 | #updated for the pool 24 | TAU_UPDATE_FREQUENCY = 1 25 | #Simulation cutoff in percentage of time 26 | #to maturity left to avoid numerical 27 | #issues close to maturity 28 | SIMULATION_CUTOFF = 5 29 | #np.random.seed() for the price generation 30 | SEED = 5 31 | # Toggle if the reference price should stay 32 | # constant throughout the simulation, thereby 33 | # ignoring the generated brownian motion. 34 | # Useful for testing. 35 | IS_CONSTANT_PRICE = False 36 | # Plot evolution of the reference price and the 37 | # pool prices. 38 | PLOT_PRICE_EVOL = True 39 | #Save figure? 40 | SAVE_PRICE_EVOL = False 41 | # Plot the evolution of the theoretical payoff and 42 | # effective payoff. 43 | PLOT_PAYOFF_EVOL = True 44 | #Save figure? 45 | SAVE_PAYOFF_EVOL = False 46 | # Plot the percentage drift between the theoretical 47 | # and effective payoff. 48 | PLOT_PAYOFF_DRIFT = False 49 | #Save figure? 50 | SAVE_PAYOFF_DRIFT = False 51 | 52 | 53 | -------------------------------------------------------------------------------- /error_distribution.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Get some information on error distribution given a fixed fee and some parameters. 3 | ''' 4 | 5 | from math import inf 6 | from joblib.parallel import Parallel, delayed 7 | from time import time 8 | import numpy as np 9 | from scipy import stats 10 | from scipy.integrate import quad 11 | import matplotlib.pyplot as plt 12 | 13 | from modules.simulate import simulate 14 | from modules import cfmm 15 | from modules import utils 16 | 17 | K = 2000 18 | volatility = 0.8 19 | tau = 0.3285421 20 | # fee = 0.005 21 | time_horizon = 0.3285421 22 | drift = 1 23 | initial_price = K*0.8 24 | dt = 0.000913242 25 | # gamma = 1 - fee 26 | 27 | N_paths = 150 28 | 29 | fees = np.linspace(0.005, 0.08, 5) 30 | 31 | plt.figure() 32 | plt.gca().set_prop_cycle(plt.cycler('color', plt.cm.hot(np.flip(np.linspace(0, 0.66, 6))))) 33 | 34 | for fee in fees: 35 | 36 | def returnError(): 37 | np.random.seed() 38 | Pool = cfmm.CoveredCallAMM(0.5, K, volatility, tau, fee) 39 | t, gbm = utils.generateGBM(time_horizon, drift, volatility, initial_price, dt) 40 | _, _, _, terminal_error = simulate(Pool, t, gbm) 41 | return terminal_error 42 | 43 | # start = time() 44 | errors = Parallel(n_jobs = -2, verbose = 0, backend = 'loky')(delayed(returnError)() for i in range(N_paths)) 45 | # end = time() 46 | # print("runtime = ", end - start) 47 | 48 | errors = np.array(errors) 49 | shape, loc, scale = stats.lognorm.fit(errors) 50 | m = np.log(scale) 51 | s = shape 52 | 53 | # if fee == fees[-1]: 54 | # binwidth = abs((max(errors) - min(errors))/(N_paths/3)) 55 | # plt.hist(errors, np.arange(min(errors), max(errors) + binwidth, binwidth), density=True) 56 | 57 | def pdf_fit(x): 58 | return stats.lognorm.pdf(x, shape, loc, scale) 59 | 60 | # print("Integral = ", quad(pdf_fit, 0, inf)[0]) 61 | 62 | gamma = 1 - fee 63 | x = np.linspace(0, 0.15, 1000) 64 | plt.plot(x, pdf_fit(x), label=r"$\gamma = {gamma}$".format(gamma=round(gamma, 5))) 65 | 66 | # plt.plot(x, pdf_fit(x), label=r"$\gamma = {gamma}$, $\mu = {mu}$, $\sigma = {sigma}$".format(gamma=round(gamma, 3))) 67 | 68 | plt.title("Distribution of errors with fixed parameters for different fees \n" + 69 | r"$\sigma = {vol}$, $\mu = {drift}$, $K = {strike}$, $d\tau = {dt}$".format(vol=volatility, drift = drift, strike=K, gam=1-fee, dt=8)+" hours" + ", Time horizon = 120 days, Initial price = 0.8*K" +" \n" + "Lognormal fits over 150 paths") 70 | 71 | plt.legend(loc="best") 72 | plt.xlabel("Terminal error") 73 | 74 | plt.show() -------------------------------------------------------------------------------- /error_distribution_arbitrage_frequency.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Get some information on error distribution given a fixed fee and some parameters. 3 | ''' 4 | 5 | from math import inf 6 | from joblib.parallel import Parallel, delayed 7 | from time import time 8 | import numpy as np 9 | from scipy import stats 10 | from scipy.integrate import quad 11 | import matplotlib.pyplot as plt 12 | 13 | from modules.simulate import simulate 14 | from modules import cfmm 15 | from modules import utils 16 | 17 | K = 2000 18 | volatility = 0.8 19 | tau = 0.3285421 20 | fee = 0.01 21 | time_horizon = 0.3285421 22 | drift = 1 23 | initial_price = K*0.8 24 | # dt = 0.000913242 25 | gamma = 1 - fee 26 | 27 | N_paths = 100 28 | 29 | time_steps_size = np.linspace(5.70776e-5, 0.000570776, 6) 30 | 31 | means = [] 32 | variances = [] 33 | 34 | plt.figure() 35 | plt.gca().set_prop_cycle(plt.cycler('color', plt.cm.hot(np.flip(np.linspace(0, 0.66, 7))))) 36 | 37 | for dt in time_steps_size: 38 | 39 | def returnError(): 40 | np.random.seed() 41 | Pool = cfmm.CoveredCallAMM(0.5, K, volatility, tau, fee) 42 | t, gbm = utils.generateGBM(time_horizon, drift, volatility, initial_price, dt) 43 | _, _, _, terminal_error = simulate(Pool, t, gbm) 44 | return terminal_error 45 | 46 | # start = time() 47 | errors = Parallel(n_jobs = -2, verbose = 0, backend = 'loky')(delayed(returnError)() for i in range(N_paths)) 48 | # end = time() 49 | # print("runtime = ", end - start) 50 | 51 | errors = np.array(errors) 52 | shape, loc, scale = stats.lognorm.fit(errors, floc=0) 53 | m = np.log(scale) 54 | s = shape 55 | means.append(np.exp(m + s**2/2)) 56 | variances.append((np.exp(s**2) - 1)*np.exp(2*m + s**2)) 57 | 58 | # if fee == fees[-1]: 59 | # binwidth = abs((max(errors) - min(errors))/(N_paths/3)) 60 | # plt.hist(errors, np.arange(min(errors), max(errors) + binwidth, binwidth), density=True) 61 | 62 | def pdf_fit(x): 63 | return stats.lognorm.pdf(x, shape, loc, scale) 64 | 65 | # print("Integral = ", quad(pdf_fit, 0, inf)[0]) 66 | 67 | x = np.linspace(0, 0.15, 1000) 68 | plt.plot(x, pdf_fit(x), label=r"$dt = {dt}$".format(dt=round(24*dt*365, 1)) + " hours") 69 | 70 | # plt.plot(x, pdf_fit(x), label=r"$\gamma = {gamma}$, $\mu = {mu}$, $\sigma = {sigma}$".format(gamma=round(gamma, 3))) 71 | 72 | plt.title("Distribution of errors with fixed parameters for different arbitrage frequencies \n" + 73 | r"$\sigma = {vol}$, $\mu = {drift}$, $K = {strike}$, $\gamma = {gam}$".format(vol=volatility, drift = drift, strike=K, gam=gamma, dt=round(24*dt*365)) + ", Time horizon = 120 days, Initial price = 0.8*K" + 74 | " \n" + "Lognormal fits over 100 paths") 75 | 76 | plt.legend(loc="best") 77 | plt.xlabel("Terminal error") 78 | 79 | plt.show(block = False) 80 | 81 | plt.figure() 82 | plt.title("Mean error as a function of arbitrage frequency (error bars = fitted variance of the distribution) \n" + 83 | r"$\sigma = {vol}$, $\mu = {drift}$, $K = {strike}$, $\gamma = {gam}$".format(vol=volatility, drift = drift, strike=K, gam=gamma, dt=round(24*dt*365)) + ", Time horizon = 120 days, Initial price = 0.8*K" + 84 | " \n" + "Lognormal fits over 100 paths") 85 | plt.errorbar(24*np.array(time_steps_size)*365, means, yerr = variances, fmt='o', capsize=5) 86 | plt.xlabel("Arbitrage frequency (hours)") 87 | plt.ylabel("Mean terminal error and variance") 88 | 89 | plt.show() -------------------------------------------------------------------------------- /modules/arb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Arbitrage function 3 | """ 4 | 5 | import scipy 6 | import numpy as np 7 | from scipy import optimize 8 | 9 | EPSILON = 1e-8 10 | 11 | 12 | def arbitrageExactly(market_price, pool): 13 | """ 14 | Arbitrage the difference *exactly* at the time of the call to the function. 15 | Uses results from the following paper: https://arxiv.org/abs/2012.08040 16 | 17 | Params: 18 | 19 | reference_price (float): 20 | the reference price of the risky asset, denominated in the riskless asset 21 | Pool (AMM object): 22 | an AMM object, for example a CoveredCallAMM class, with some current state and reserves 23 | """ 24 | gamma = 1 - pool.fee 25 | R1 = pool.reserves_risky 26 | R2 = pool.reserves_riskless 27 | K = pool.K 28 | k = pool.invariant 29 | sigma = pool.sigma 30 | tau = pool.tau 31 | 32 | # Marginal price of selling epsilon risky 33 | # price_sell_risky = gamma*K*norm.pdf(norm.ppf(1 - R1) - sigma*np.sqrt(tau))*quantilePrime(1 - R1) 34 | price_sell_risky = pool.getMarginalPriceSwapRiskyIn(0) 35 | # Marginal price of buying epsilon risky 36 | price_buy_risky = pool.getMarginalPriceSwapRisklessIn(0) 37 | 38 | # Market price 39 | m = market_price 40 | 41 | # If the risky reserves are almost empty 42 | if R1 < EPSILON: 43 | return 44 | # or if the riskless reserves are almost empty 45 | elif R2 < EPSILON or (K + k - R2) / gamma < EPSILON: 46 | return 47 | 48 | # or if the risky reserves are almost full 49 | elif 1 - R1 < EPSILON: 50 | return 51 | 52 | # or if the riskless reserves are almost full 53 | elif K - R2 < EPSILON: 54 | return 55 | 56 | # In any of the above cases, we do nothing, this ensures that the bracketing for the root finding will always test a positive amount in. 57 | 58 | # If the price of selling epsilon of the risky asset is above the market price, we buy the optimal amount of the risky asset on the market and immediately sell it on the CFMM = **swap amount in risky**. 59 | elif price_sell_risky > m + 1e-8: 60 | # Solve for the optimal amount in 61 | def func(amount_in): 62 | return pool.getMarginalPriceSwapRiskyIn(amount_in) - m 63 | 64 | # If the sign is the same for the bounds of the possible trades, this means that the arbitrager can empty the pool while maximizing his profit (the profit may still be negative, even though maximum) 65 | if (np.sign(func(EPSILON)) != np.sign(func(1 - R1 - EPSILON))): 66 | optimal_trade = scipy.optimize.brentq(func, EPSILON, 1 - R1 - EPSILON) 67 | else: 68 | optimal_trade = 1 - R1 69 | assert optimal_trade >= 0 70 | amount_out, _ = pool.virtualSwapAmountInRisky(optimal_trade) 71 | # The amount of the riskless asset we get after making the swap must be higher than the value in the riskless asset at which we obtained the amount in on the market 72 | profit = amount_out - optimal_trade * m 73 | if profit > 0: 74 | _, _ = pool.swapAmountInRisky(optimal_trade) 75 | 76 | # If the price of buying epsilon of the risky asset is below the market price, we buy the optimal amount of the risky asset in the CFMM and immediately sell it on the market = **swap amount in riskless** in the CFMM. 77 | elif price_buy_risky < m - 1e-8: 78 | def func(amount_in): 79 | return m - pool.getMarginalPriceSwapRisklessIn(amount_in) 80 | 81 | # If the sign is the same for the bounds of the possible trades, this means that the arbitrager can empty the pool while maximizing his profit (the profit may still be negative, even though maximum) 82 | if (np.sign(func(EPSILON)) != np.sign(func((K + k - R2) / gamma - EPSILON))): 83 | optimal_trade = scipy.optimize.brentq(func, EPSILON, (K + k - R2) / gamma - EPSILON) 84 | else: 85 | optimal_trade = K - R2 86 | assert optimal_trade >= 0 87 | amount_out, _ = pool.virtualSwapAmountInRiskless(optimal_trade) 88 | # The amount of risky asset we get out times the market price must result in an amount of riskless asset higher than what we initially put in the CFMM 89 | profit = amount_out * m - optimal_trade 90 | if profit > 0: 91 | _, _ = pool.swapAmountInRiskless(optimal_trade) 92 | -------------------------------------------------------------------------------- /modules/cfmm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the necessary AMM logic. 3 | """ 4 | 5 | import math 6 | from math import inf 7 | import scipy 8 | from scipy.stats import norm 9 | from scipy import optimize 10 | import numpy as np 11 | 12 | from modules.utils import nonnegative, quantilePrime, blackScholesCoveredCallSpotPrice 13 | 14 | EPSILON = 1e-8 15 | 16 | 17 | class CoveredCallAMM(object): 18 | """ 19 | A class to represent a two-tokens AMM with the covered call trading function. 20 | 21 | Attributes 22 | ___________ 23 | 24 | reserves_risky: float 25 | the reserves of the AMM pool in the risky asset 26 | reserves_riskless: float 27 | the reserves of the AMM pool in the riskless asset 28 | tau: float 29 | the time to maturity for this pool in the desired units 30 | K: float 31 | the strike price for this pool 32 | sigma: float 33 | the volatility for this pool, scaled to be consistent with the unit of tau (annualized if tau is in years etc) 34 | invariant: float 35 | the invariant of the CFMM 36 | """ 37 | 38 | def __init__(self, initial_x, k, sigma, tau, fee): 39 | """ 40 | Initialize the AMM pool with a starting risky asset reserve as an 41 | input, calculate the corresponding riskless asset reserve needed to 42 | satisfy the trading function equation. 43 | """ 44 | self.reserves_risky = initial_x 45 | self.K = k 46 | self.sigma = sigma 47 | self.tau = tau 48 | self.initial_tau = tau 49 | self.invariant = 0 50 | self.reserves_riskless = self.K * norm.cdf(norm.ppf(1 - initial_x) - self.sigma * np.sqrt(self.tau)) 51 | self.fee = fee 52 | self.accured_fees = [0, 0] 53 | 54 | def getRisklessGivenRisky(self, risky): 55 | return self.invariant + self.K * norm.cdf(norm.ppf(1 - risky) - self.sigma * np.sqrt(self.tau)) 56 | 57 | def getRisklessGivenRiskyNoInvariant(self, risky): 58 | return self.K * norm.cdf(norm.ppf(1 - risky) - self.sigma * np.sqrt(self.tau)) 59 | 60 | def getRiskyGivenRiskless(self, riskless): 61 | return 1 - norm.cdf(norm.ppf((riskless - self.invariant) / self.K) + self.sigma * np.sqrt(self.tau)) 62 | 63 | def swapAmountInRisky(self, amount_in): 64 | """ 65 | Swap in some amount of the risky asset and get some amount of the riskless asset in return. 66 | 67 | Returns: 68 | 69 | amount_out: the amount to be given out to the trader 70 | effective_price_in_risky: the effective price of the executed trade 71 | """ 72 | assert nonnegative(amount_in) 73 | gamma = 1 - self.fee 74 | new_reserves_riskless = self.getRisklessGivenRisky(self.reserves_risky + gamma * amount_in) 75 | amount_out = self.reserves_riskless - new_reserves_riskless 76 | self.reserves_risky += amount_in 77 | self.reserves_riskless -= amount_out 78 | assert nonnegative(new_reserves_riskless) 79 | # Update invariant 80 | self.invariant = self.reserves_riskless - self.getRisklessGivenRiskyNoInvariant(self.reserves_risky) 81 | effective_price_in_riskless = amount_out / amount_in 82 | return amount_out, effective_price_in_riskless 83 | 84 | def virtualSwapAmountInRisky(self, amount_in): 85 | """ 86 | Perform a swap and then revert the state of the pool. 87 | 88 | Returns: 89 | 90 | amount_out: the amount that the trader would get out given the amount in 91 | effective_price_in_riskless: the effective price the trader would pay for that 92 | trade denominated in the riskless asset 93 | """ 94 | assert nonnegative(amount_in) 95 | gamma = 1 - self.fee 96 | new_reserves_riskless = self.getRisklessGivenRisky(self.reserves_risky + gamma * amount_in) 97 | if new_reserves_riskless <= 0 or math.isnan(new_reserves_riskless): 98 | return 0, 0 99 | assert nonnegative(new_reserves_riskless) 100 | amount_out = self.reserves_riskless - new_reserves_riskless 101 | if amount_in == 0: 102 | effective_price_in_riskless = inf 103 | else: 104 | effective_price_in_riskless = amount_out / amount_in 105 | return amount_out, effective_price_in_riskless 106 | 107 | def swapAmountInRiskless(self, amount_in): 108 | """ 109 | Swap in some amount of the riskless asset and get some amount of the risky asset in return. 110 | 111 | Returns: 112 | 113 | amount_out: the amount to be given to the trader 114 | effective_price_in_riskless: the effective price the trader actually paid for that trade 115 | denominated in the riskless asset 116 | """ 117 | assert nonnegative(amount_in) 118 | gamma = 1 - self.fee 119 | new_reserves_risky = self.getRiskyGivenRiskless(self.reserves_riskless + gamma * amount_in) 120 | assert nonnegative(new_reserves_risky) 121 | amount_out = self.reserves_risky - new_reserves_risky 122 | assert nonnegative(amount_out) 123 | self.reserves_riskless += amount_in 124 | self.reserves_risky -= amount_out 125 | # Update invariant 126 | self.invariant = self.reserves_riskless - self.getRisklessGivenRiskyNoInvariant(self.reserves_risky) 127 | if amount_in == 0: 128 | effective_price_in_riskless = inf 129 | else: 130 | effective_price_in_riskless = amount_in / amount_out 131 | return amount_out, effective_price_in_riskless 132 | 133 | def virtualSwapAmountInRiskless(self, amount_in): 134 | """ 135 | Perform a swap and then revert the state of the pool. 136 | 137 | Returns: 138 | 139 | amount_out: the amount that the trader would get out given the amount in 140 | effective_price_in_riskless: the effective price the trader would pay for that 141 | trade denominated in the riskless asset 142 | """ 143 | assert nonnegative(amount_in) 144 | gamma = 1 - self.fee 145 | new_reserves_risky = self.getRiskyGivenRiskless(self.reserves_riskless + gamma * amount_in) 146 | if new_reserves_risky <= 0 or math.isnan(new_reserves_risky): 147 | return 0, 0 148 | assert nonnegative(new_reserves_risky) 149 | amount_out = self.reserves_risky - new_reserves_risky 150 | if amount_out == 0: 151 | effective_price_in_riskless = inf 152 | else: 153 | effective_price_in_riskless = amount_in / amount_out 154 | return amount_out, effective_price_in_riskless 155 | 156 | def getSpotPrice(self): 157 | """ 158 | Get the current spot price (ie "reported price" using CFMM jargon) of 159 | the risky asset, denominated in the riskless asset, only exact in the 160 | no-fee case. 161 | """ 162 | return blackScholesCoveredCallSpotPrice(self.reserves_risky, self.K, self.sigma, self.tau) 163 | 164 | def getMarginalPriceSwapRiskyIn(self, amount_in): 165 | """ 166 | Returns the marginal price after a trade of size amount_in (in the 167 | risky asset) with the current reserves (in RISKLESS.RISKY-1). 168 | See https://arxiv.org/pdf/2012.08040.pdf 169 | """ 170 | assert nonnegative(amount_in) 171 | gamma = 1 - self.fee 172 | r = self.reserves_risky 173 | K = self.K 174 | sigma = self.sigma 175 | tau = self.tau 176 | return gamma * K * norm.pdf(norm.ppf(float(1 - r - gamma * amount_in)) - sigma * np.sqrt(tau)) * quantilePrime( 177 | 1 - r - gamma * amount_in) 178 | 179 | def getMarginalPriceSwapRisklessIn(self, amount_in): 180 | """ 181 | Returns the marginal price after a trade of size amount_in (in the 182 | riskless asset) with the current reserves (in RISKLESS.RISKY-1) 183 | See https://arxiv.org/pdf/2012.08040.pdf 184 | """ 185 | assert nonnegative(amount_in) 186 | gamma = 1 - self.fee 187 | R = self.reserves_riskless 188 | invariant = self.invariant 189 | K = self.K 190 | sigma = self.sigma 191 | tau = self.tau 192 | if ((gamma * norm.pdf(norm.ppf(float((R + gamma * amount_in - invariant) / K)) + sigma * np.sqrt(tau)) * 193 | quantilePrime((R + gamma * amount_in - invariant) / K) * (1 / K)) < EPSILON): 194 | # Infinity 195 | return 1e8 196 | else: 197 | return 1 / (gamma * norm.pdf( 198 | norm.ppf(float((R + gamma * amount_in - invariant) / K)) + sigma * np.sqrt(tau)) * quantilePrime( 199 | (R + gamma * amount_in - invariant) / K) * (1 / K)) 200 | 201 | def getRiskyReservesGivenSpotPrice(self, s): 202 | """ 203 | Given some spot price S in the no-fee case, get the risky reserves corresponding to that 204 | spot price by solving the S = -y' = -f'(x) for x. 205 | """ 206 | 207 | def func(x): 208 | return s - blackScholesCoveredCallSpotPrice(x, self.K, self.sigma, self.tau) 209 | 210 | sol = scipy.optimize.root(func, self.reserves_risky) 211 | reserves_risky = sol.x[0] 212 | return reserves_risky 213 | -------------------------------------------------------------------------------- /modules/optimize_fee.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Contains a set of functions used to find the optimal fee for a given set of parameters. 3 | ''' 4 | 5 | import gc 6 | import numpy as np 7 | import scipy 8 | from scipy.optimize import minimize_scalar 9 | 10 | from joblib import Parallel, delayed 11 | 12 | from modules.utils import generateGBM 13 | from modules.simulate import simulate 14 | from modules import cfmm 15 | 16 | def returnErrors(fee, initial_tau, timestep_size, time_horizon, volatility, drift, strike, initial_price): 17 | ''' 18 | Given some parameters and a gbm, return the errors under 19 | optimal arbitrage for that gbm 20 | ''' 21 | np.random.seed() 22 | t, gbm = generateGBM(time_horizon, drift, volatility, initial_price, timestep_size) 23 | Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 24 | _, _, mean_error, terminal_error = simulate(Pool, t, gbm) 25 | del Pool 26 | del gbm 27 | del t 28 | gc.collect() 29 | return mean_error, terminal_error 30 | 31 | def findOptimalFee(initial_tau, time_steps_size, time_horizon, volatility, drift, strike, initial_price): 32 | ''' 33 | Given some parameters, return the fee that minimizes the maximum between the mean square 34 | error and the terminal square error (square of the error at the last step of the 35 | simulation) 36 | ''' 37 | 38 | def ErrorFromFee(fee): 39 | ''' 40 | Return the max of the average mse and average terminal square error from 100 41 | simulations with different price actions given these parameters 42 | ''' 43 | # DEBUGGING 44 | # print("fee = ", fee) 45 | # results = [] 46 | # for i in range(50): 47 | # print("STEP ", i) 48 | # results.append(returnErrors(fee, initial_tau, time_steps_size, time_horizon, volatility, drift, strike, initial_price)) 49 | 50 | results = Parallel(n_jobs=-1, verbose=0, backend='loky')(delayed(returnErrors)(fee, initial_tau, time_steps_size, time_horizon, volatility, drift, strike, initial_price) for i in range(50)) 51 | average_error = np.mean([item[0] for item in results]) 52 | average_terminal_error = np.mean([item[1] for item in results]) 53 | del results 54 | gc.collect() 55 | # return max(average_error, average_terminal_error) 56 | return average_terminal_error 57 | 58 | #Look for the optimal fee with a tolerance of +/- 0.5% or 50 bps 59 | # sol = minimize_scalar(ErrorFromFee, bracket=(0.0001, 0.15), method='Golden', tol = 0.005) 60 | sol = scipy.optimize.fminbound(ErrorFromFee, 0.0001, 0.10, xtol = 0.0005) 61 | optimal_fee = sol 62 | return optimal_fee 63 | -------------------------------------------------------------------------------- /modules/simulate.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Functions used to run an actual simulation 3 | ''' 4 | 5 | import numpy as np 6 | 7 | from modules.arb import arbitrageExactly 8 | from modules.utils import getRiskyGivenSpotPriceWithDelta, getRiskyReservesGivenSpotPrice, getRisklessGivenRisky 9 | 10 | 11 | def simulate(Pool, t, gbm): 12 | ''' 13 | A function which, given a pool, a time array, and a geometric brownian 14 | motion, for that time array, returns the results of a simulation of that pool 15 | under optimal arbitrage. 16 | ''' 17 | np.random.seed() 18 | # Array to store the theoretical value of LP shares in the case of a pool with zero fees 19 | theoretical_lp_value_array = [] 20 | # Effective value of LP shares with fees 21 | effective_lp_value_array = [] 22 | initial_tau = Pool.initial_tau 23 | for i in range(len(gbm)): 24 | theoretical_tau = initial_tau - t[i] 25 | Pool.tau = initial_tau - t[i] 26 | #Hack to avoid slightly negative values of tau 27 | if Pool.tau < 0: 28 | Pool.tau = 0 29 | Pool.invariant = Pool.reserves_riskless - Pool.getRisklessGivenRiskyNoInvariant(Pool.reserves_risky) 30 | arbitrageExactly(gbm[i], Pool) 31 | theoretical_reserves_risky = getRiskyGivenSpotPriceWithDelta(gbm[i], Pool.K, Pool.sigma, theoretical_tau) 32 | theoretical_reserves_riskless = getRisklessGivenRisky(theoretical_reserves_risky, Pool.K, Pool.sigma, theoretical_tau) 33 | theoretical_lp_value = theoretical_reserves_risky*gbm[i] + theoretical_reserves_riskless 34 | theoretical_lp_value_array.append(theoretical_lp_value) 35 | effective_lp_value_array.append(Pool.reserves_risky*gbm[i] + Pool.reserves_riskless) 36 | theoretical_lp_value_array = np.array(theoretical_lp_value_array) 37 | effective_lp_value_array = np.array(effective_lp_value_array) 38 | mean_error = np.abs(np.subtract(theoretical_lp_value_array, effective_lp_value_array)/theoretical_lp_value_array).mean() 39 | terminal_error = np.abs((theoretical_lp_value_array[-1] - effective_lp_value_array[-1])/(theoretical_lp_value_array[-1])) 40 | return theoretical_lp_value_array, effective_lp_value_array, mean_error, terminal_error 41 | -------------------------------------------------------------------------------- /modules/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | A collection of utility functions including some 3 | covered call AMM relevant math. 4 | """ 5 | 6 | import math 7 | from math import inf 8 | import numpy as np 9 | from scipy.optimize import newton 10 | from scipy.stats import norm 11 | 12 | 13 | def nonnegative(x): 14 | if isinstance(x, np.ndarray): 15 | return (x >= 0).all() 16 | return x >= 0 17 | 18 | 19 | def blackScholesCoveredCall(x, K, sigma, tau): 20 | """ 21 | Return value of the BS covered call trading function for given reserves and parameters. 22 | """ 23 | result = x[1] - K * norm.cdf(norm.ppf(1 - x[0]) - sigma * np.sqrt(tau)) 24 | return result 25 | 26 | 27 | def quantilePrime(x): 28 | """ 29 | Analytical formula for the derivative of the quantile function (inverse of 30 | the CDF). 31 | """ 32 | EPSILON = 1e-16 33 | if (x > 1 - EPSILON) or (x < 0 + EPSILON): 34 | return inf 35 | else: 36 | return norm.pdf(norm.ppf(x)) ** -1 37 | 38 | 39 | def blackScholesCoveredCallSpotPrice(x, K, sigma, tau): 40 | """ 41 | Analytical formula for the spot price (reported price) of the BS covered 42 | call CFMM in the zero fees case. 43 | """ 44 | return K * norm.pdf(norm.ppf(1 - x) - sigma * np.sqrt(tau)) * quantilePrime(1 - x) 45 | 46 | 47 | # Functions for analytic zero fees spot price and reserves calculations 48 | def getRiskyReservesGivenSpotPrice(S, K, sigma, tau): 49 | """ 50 | Given some spot price S, get the risky reserves corresponding to that spot price by solving 51 | S = -y' = -f'(x) for x. Only useful in the no-fee case. 52 | """ 53 | 54 | def func(x): 55 | return S - blackScholesCoveredCallSpotPrice(x, K, sigma, tau) 56 | 57 | if S > K: 58 | sol, r = newton(func, 0.01, maxiter=100, disp=False, full_output=True) 59 | else: 60 | sol, r = newton(func, 0.5, maxiter=100, disp=False, full_output=True) 61 | reserves_risky = r.root 62 | # The reserves almost don't change anymore at the boundaries, so if we haven't 63 | # converged, we return what we logically know to be very close to the actual 64 | # reserves. 65 | if math.isnan(reserves_risky) and S > K: 66 | return 0 67 | elif math.isnan(reserves_risky) and S < K: 68 | return 1 69 | return reserves_risky 70 | 71 | 72 | def getRiskyGivenSpotPriceWithDelta(S, K, sigma, tau): 73 | """ 74 | Given some spot price S, get the risky reserves corresponding to that spot price using results 75 | from options theory, thereby avoiding the use of an iterative solver. 76 | """ 77 | if tau <= 0: 78 | if S > K: 79 | return 0 80 | if S < K: 81 | return 1 82 | else: 83 | d1 = (np.log(S / K) + (tau * (sigma ** 2) / 2)) / (sigma * np.sqrt(tau)) 84 | return 1 - norm.cdf(d1) 85 | 86 | 87 | def getRisklessGivenRisky(risky, K, sigma, tau): 88 | if risky == 0: 89 | return K 90 | elif risky == 1: 91 | return 0 92 | return K * norm.cdf(norm.ppf(1 - risky) - sigma * np.sqrt(tau)) 93 | 94 | 95 | def generateGBM(T, mu, sigma, S0, dt): 96 | """ 97 | Generate a geometric brownian motion time series. Shamelessly copy pasted from here: https://stackoverflow.com/a/13203189 98 | 99 | Params: 100 | 101 | T: time horizon 102 | mu: drift 103 | sigma: percentage volatility 104 | S0: initial price 105 | dt: size of time steps 106 | 107 | Returns: 108 | 109 | t: time array 110 | S: time series 111 | """ 112 | N = round(T / dt) 113 | t = np.linspace(0, T, N) 114 | W = np.random.standard_normal(size=N) 115 | W = np.cumsum(W) * np.sqrt(dt) ### standard brownian motion ### 116 | X = (mu - 0.5 * sigma ** 2) * t + sigma * W 117 | S = S0 * np.exp(X) ### geometric brownian motion ### 118 | return t, S 119 | -------------------------------------------------------------------------------- /optimal_fees_parallel.py: -------------------------------------------------------------------------------- 1 | from joblib.parallel import Parallel, delayed 2 | import numpy as np 3 | import json 4 | import time 5 | from datetime import datetime 6 | from pathlib import Path 7 | 8 | from modules import optimize_fee 9 | 10 | # Script used to map a tuple of (volatility, drift, strike price) to the optial 11 | # fee to choose for the pool, i.e. the fee that minimizes the max of the mean 12 | # square error and the terminal square error. 13 | 14 | # Currently for the case of 30 days to maturity with 1 hour tau update / arbitrage 15 | # intervals. 16 | 17 | # Initial time to maturity in years 18 | INITIAL_TAU = 0.328767 #120 days 19 | # Time horizon of the GBM in years 20 | TIME_HORIZON = 0.3287672 21 | # Time step size of the GBM in years = 22 | # tau update frequency = arbitrage frequency 23 | # TIME_STEPS_SIZE = 0.000456621 #4 hours 24 | TIME_STEPS_SIZE = 0.000913242 #8 hours 25 | # Arbitrary strike price, what matters is the difference between 26 | # initial price and strike price 27 | STRIKE = 2000 28 | 29 | 30 | # Array storing set of parameters to explore: volatility, drift, 31 | # initial price distance from strike price* 32 | # *for example if the strike priced is K, a parameter of 0.8 will 33 | # start the simulation with an initial price of 0.8*K 34 | 35 | min_vol = 0.7 36 | max_vol = 1.5 37 | min_drift = 1 38 | max_drift = 1 39 | min_distance = 0.7 40 | max_distance = 0.9 41 | N_vol = 1 42 | N_drift = 1 43 | N_distance = 1 44 | 45 | parameters = [np.linspace(min_vol, max_vol, N_vol), np.linspace(min_drift, max_drift, N_drift), np.linspace(min_distance, max_distance, N_distance)] 46 | # optimal_fee_array = [[0 for i in range(len(parameters[0]))], [0 for i in range(len(parameters[1]))], [0 for i in range(len(parameters[2]))]] 47 | optimal_fee_array = [ 48 | [ 49 | [0 for i in range(len(parameters[2]))] for i in range(len(parameters[1])) 50 | ] for i in range(len(parameters[0])) 51 | ] 52 | 53 | def findOptimalFeeParallel(volatility, drift, strike_proportion): 54 | return optimize_fee.findOptimalFee(INITIAL_TAU, TIME_STEPS_SIZE, TIME_HORIZON, volatility, drift, STRIKE, STRIKE*strike_proportion) 55 | 56 | start_nested = time.time() 57 | 58 | # With parallelization of the main loop 59 | 60 | # Needs to be debugged: takes longer than non nested parallelization for some reason 61 | 62 | # optimal_fee_array = Parallel(n_jobs=-1, verbose=0, backend='loky')(delayed(findOptimalFeeParallel)(volatility, drift, strike_proportion) for strike_proportion in parameters[2] for drift in parameters[1] for volatility in parameters[0]) 63 | 64 | # end_nested = time.time() 65 | 66 | start = time.time() 67 | 68 | # Without parallelization of the main loop 69 | 70 | for i in range(len(parameters[0])): 71 | for j in range(len(parameters[1])): 72 | for m in range(len(parameters[2])): 73 | volatility = parameters[0][i] 74 | drift = parameters[1][j] 75 | strike_proportion = parameters[2][m] 76 | initial_price = STRIKE*strike_proportion 77 | print("Volatility = ", volatility) 78 | print("Drift = ", drift) 79 | print("strike proportion = ", strike_proportion) 80 | optimal_fee = optimize_fee.findOptimalFee(INITIAL_TAU, TIME_STEPS_SIZE, TIME_HORIZON, volatility, drift, STRIKE, STRIKE*strike_proportion) 81 | print("Optimal fee = ", optimal_fee), "\n" 82 | optimal_fee_array[i][j][m] = optimal_fee 83 | 84 | end = time.time() 85 | 86 | print("Without nested Joblib: ", end - start) 87 | # print("With nested Joblib: ", end_nested - start_nested) 88 | 89 | data = {} 90 | data['parameters'] = [parameters[0].tolist(), parameters[1].tolist(), parameters[2].tolist()] 91 | data['optimal_fees'] = optimal_fee_array 92 | now = datetime.now() 93 | dt_string = now.strftime("%d-%m-%Y_%H-%M-%S") 94 | filename = 'optimization_results_'+ dt_string + '.dat' 95 | Path('optimization_results').mkdir(parents=True, exist_ok=True) 96 | with open('optimization_results/'+filename, 'w+') as f: 97 | json.dump(data, f) 98 | 99 | 100 | -------------------------------------------------------------------------------- /optimal_fees_visualization.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | 4 | parameters = [[0.7, 0.8999999999999999, 1.1, 1.3, 1.5], [-1.0, -0.5, 0.0, 0.5, 1.0], [0.7, 0.8, 0.9]] 5 | optimal_fees = [[[0.026798073681136254, 0.03269116833280147, 0.046998996005887826], [0.03375176790259808, 0.04674784780011286, 0.03809173728975499], [0.03457317626548741, 0.0382584045238855, 0.04726638190445798], [0.0361319095222899, 0.060369646180426, 0.061841595476114494], [0.06009152991552148, 0.06740883166719853, 0.07084957285668697]], [[0.0421022668995366, 0.05913666492422208, 0.059715100474518897], [0.05521419504100678, 0.0762501412476599, 0.07139226920863585], [0.051838146519104455, 0.0747999966220643, 0.06740883166719853], [0.055020801421762244, 0.06887490024162703, 0.06441958601107912], [0.07588131792607125, 0.07458109704293202, 0.06200826306004025]], [[0.06207446959929257, 0.06930504721245889, 0.08198404523885505], [0.06707675689487061, 0.06992993721287889, 0.07729827993350274], [0.0640893261674698, 0.06625338619924143, 0.07172511912960798], [0.07660855498244842, 0.07394523612262581, 0.08542478642834349], [0.06595494041045259, 0.07503799074706362, 0.07763842915253565]], [[0.07641680904777101, 0.07607619164856146, 0.07818060569804852], [0.08542478642834349, 0.09361736529212264, 0.061841595476114494], [0.08542478642834349, 0.06278983717105165, 0.07658347684788212], [0.08755128142993909, 0.08523237674409338, 0.06735329927102571], [0.08270013931323478, 0.080394454058747, 0.07041645146971968]], [[0.07698783127874857, 0.07782045945265517, 0.0757381965155289], [0.06470632546394495, 0.08885570212757923, 0.0751830006711448], [0.08542478642834349, 0.07170023391956667, 0.09616198683490837], [0.08608415395287941, 0.07641680904777101, 0.07641680904777101], [0.07414668724988627, 0.09009599738777128, 0.07813145840471823]]] 6 | 7 | x = parameters[0] 8 | y = parameters[2] 9 | drift_index = 4 10 | 11 | x, y = np.mgrid[slice(x[0], x[-1] + (1.5-0.7)/4, (1.5-0.7)/4), slice(y[0], y[-1] + (0.9-0.7)/2, (0.9-0.7)/2)] 12 | 13 | z = x.copy() 14 | 15 | print(optimal_fees[0][4][0]) 16 | 17 | for i in range(len(z)): 18 | for j in range(len(z[i])): 19 | z[i,j] = optimal_fees[i][drift_index][j] 20 | 21 | z_min, z_max = np.abs(z).min(), np.abs(z).max() 22 | 23 | c = plt.imshow(z, cmap ='Blues', vmin = z_min, vmax = z_max, 24 | extent =[x.min(), x.max(), y.min(), y.max()], 25 | interpolation ='nearest', origin ='lower', aspect='auto') 26 | 27 | plt.colorbar(c) 28 | 29 | plt.xlabel("Volatility") 30 | plt.ylabel("Initial price as fraction of strike") 31 | 32 | plt.title("Optimal fees for different parameters \n" + r'Drift = 1, 120 days, 8H $d\tau$') 33 | plt.show() -------------------------------------------------------------------------------- /simulation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run an individual simulation from the config.ini parameters 3 | and display and/or record the results. 4 | """ 5 | 6 | from configparser import ConfigParser 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | 11 | from modules import cfmm 12 | from modules.arb import arbitrageExactly 13 | from modules.utils import getRiskyGivenSpotPriceWithDelta, getRisklessGivenRisky, generateGBM 14 | 15 | EPSILON = 1e-8 16 | 17 | #Import config 18 | config_object = ConfigParser() 19 | config_object.read("config.ini") 20 | 21 | STRIKE_PRICE = float(config_object.get("Pool parameters", "STRIKE_PRICE")) 22 | TIME_TO_MATURITY = float(config_object.get("Pool parameters", "TIME_TO_MATURITY")) 23 | FEE = float(config_object.get("Pool parameters", "FEE")) 24 | 25 | INITIAL_REFERENCE_PRICE = float(config_object.get("Price action parameters", "INITIAL_REFERENCE_PRICE")) 26 | ANNUALIZED_VOL = float(config_object.get("Price action parameters", "ANNUALIZED_VOL")) 27 | DRIFT = float(config_object.get("Price action parameters", "DRIFT")) 28 | TIME_HORIZON = float(config_object.get("Price action parameters", "TIME_HORIZON")) 29 | TIME_STEPS_SIZE = float(config_object.get("Price action parameters", "TIME_STEPS_SIZE")) 30 | 31 | TAU_UPDATE_FREQUENCY = float(config_object.get("Simulation parameters", "TAU_UPDATE_FREQUENCY")) 32 | SIMULATION_CUTOFF = float(config_object.get("Simulation parameters", "SIMULATION_CUTOFF")) 33 | SEED = int(config_object.get("Simulation parameters", "SEED")) 34 | 35 | IS_CONSTANT_PRICE = config_object.getboolean("Simulation parameters", "IS_CONSTANT_PRICE") 36 | PLOT_PRICE_EVOL = config_object.getboolean("Simulation parameters", "PLOT_PRICE_EVOL") 37 | PLOT_PAYOFF_EVOL = config_object.getboolean("Simulation parameters", "PLOT_PAYOFF_EVOL") 38 | PLOT_PAYOFF_DRIFT = config_object.getboolean("Simulation parameters", "PLOT_PAYOFF_DRIFT") 39 | SAVE_PRICE_EVOL = config_object.getboolean("Simulation parameters", "SAVE_PRICE_EVOL") 40 | SAVE_PAYOFF_EVOL = config_object.getboolean("Simulation parameters", "SAVE_PAYOFF_EVOL") 41 | SAVE_PAYOFF_DRIFT = config_object.getboolean("Simulation parameters", "SAVE_PAYOFF_DRIFT") 42 | 43 | #Initialize pool parameters 44 | sigma = ANNUALIZED_VOL 45 | initial_tau = TIME_TO_MATURITY 46 | K = STRIKE_PRICE 47 | fee = FEE 48 | gamma = 1 - FEE 49 | np.random.seed(SEED) 50 | 51 | #Stringify for plotting 52 | gamma_str = str(1 - fee) 53 | sigma_str = str(sigma) 54 | K_str = str(K) 55 | 56 | #Initialize pool and arbitrager objects 57 | Pool = cfmm.CoveredCallAMM(0.5, K, sigma, initial_tau, fee) 58 | 59 | #Initialize GBM parameters 60 | T = TIME_HORIZON 61 | dt = TIME_STEPS_SIZE 62 | S0 = INITIAL_REFERENCE_PRICE 63 | 64 | 65 | t, S = generateGBM(T, DRIFT, ANNUALIZED_VOL, S0, dt) 66 | 67 | if IS_CONSTANT_PRICE: 68 | length = len(S) 69 | constant_price = [] 70 | for i in range(length): 71 | constant_price.append(S0) 72 | S = constant_price 73 | 74 | plt.plot(t, S) 75 | plt.show() 76 | 77 | # Prepare storage variables 78 | 79 | # Store spot prices after each step 80 | spot_price_array = [] 81 | # Marginal price affter each step 82 | min_marginal_price_array = [] 83 | max_marginal_price_array = [] 84 | 85 | # Array to store the theoretical value of LP shares in the case of a pool with zero fees 86 | theoretical_lp_value_array = [] 87 | # Effective value of LP shares with fees 88 | effective_lp_value_array = [] 89 | 90 | dtau = TAU_UPDATE_FREQUENCY 91 | 92 | for i in range(len(S)): 93 | 94 | #Update pool's time to maturity 95 | theoretical_tau = initial_tau - t[i] 96 | 97 | if i % dtau == 0: 98 | Pool.tau = initial_tau - t[i] 99 | #Changing tau changes the value of the invariant even if no trade happens 100 | Pool.invariant = Pool.reserves_riskless - Pool.getRisklessGivenRiskyNoInvariant(Pool.reserves_risky) 101 | spot_price_array.append(Pool.getSpotPrice()) 102 | # _, max_marginal_price = Pool.virtualSwapAmountInRiskless(EPSILON) 103 | # _, min_marginal_price = Pool.virtualSwapAmountInRisky(EPSILON) 104 | 105 | if Pool.tau >= 0: 106 | #Perform arbitrage step 107 | arbitrageExactly(S[i], Pool) 108 | max_marginal_price_array.append(Pool.getMarginalPriceSwapRisklessIn(0)) 109 | min_marginal_price_array.append(Pool.getMarginalPriceSwapRiskyIn(0)) 110 | #Get reserves given the reference price in the zero fees case 111 | theoretical_reserves_risky = getRiskyGivenSpotPriceWithDelta(S[i], Pool.K, Pool.sigma, theoretical_tau) 112 | theoretical_reserves_riskless = getRisklessGivenRisky(theoretical_reserves_risky, Pool.K, Pool.sigma, theoretical_tau) 113 | theoretical_lp_value = theoretical_reserves_risky*S[i] + theoretical_reserves_riskless 114 | theoretical_lp_value_array.append(theoretical_lp_value) 115 | effective_lp_value_array.append(Pool.reserves_risky*S[i] + Pool.reserves_riskless) 116 | if Pool.tau < 0: 117 | max_index = i 118 | break 119 | max_index = i 120 | 121 | # plt.plot(fees, mse, 'o') 122 | # plt.xlabel("Fee") 123 | # plt.ylabel("MSE") 124 | # plt.title("Mean square error with theoretical payoff as a function of the fee parameter\n" + r"$\sigma = 0.5$, $K = 1100$, $\gamma = 1$, $\mathrm{d}\tau = 30 \ \mathrm{days}$") 125 | # plt.show() 126 | 127 | theoretical_lp_value_array = np.array(theoretical_lp_value_array) 128 | effective_lp_value_array = np.array(effective_lp_value_array) 129 | 130 | #Mean square error 131 | mse = np.square(np.subtract(theoretical_lp_value_array, effective_lp_value_array)/theoretical_lp_value_array).mean() 132 | 133 | if PLOT_PRICE_EVOL: 134 | plt.plot(t[0:max_index], S[0:max_index], label = "Reference price") 135 | # plt.plot(t[0:max_index], spot_price_array, label = "Pool spot price") 136 | plt.plot(t[0:max_index], min_marginal_price_array[0:max_index], label = "Price sell risky") 137 | plt.plot(t[0:max_index], max_marginal_price_array[0:max_index], label = "Price buy risky") 138 | plt.title("Arbitrage between CFMM and reference price\n" + r"$\sigma = {vol}$, $K = {strike}$, $\gamma = {gam}$, $\tau_0 = {tau}$, $d\tau = {dt}$".format(vol=ANNUALIZED_VOL, strike=STRIKE_PRICE, gam=round(1-FEE, 3), dt=round(24*TIME_STEPS_SIZE*365), tau = TIME_TO_MATURITY)+" hours"+ ", np.seed("+str(SEED)+")") 139 | plt.xlabel("Time steps (years)") 140 | plt.ylabel("Price (USD)") 141 | plt.legend(loc='best') 142 | params_string = "sigma"+str(ANNUALIZED_VOL)+"_K"+str(STRIKE_PRICE)+"_gamma"+str(gamma)+"_dtau"+str(TIME_STEPS_SIZE)+"_seed"+str(SEED) 143 | filename = 'price_evol_'+params_string+'.svg' 144 | plt.plot() 145 | if SAVE_PRICE_EVOL: 146 | plt.savefig('sim_results/'+filename) 147 | plt.show(block = False) 148 | 149 | if PLOT_PAYOFF_EVOL: 150 | plt.figure() 151 | plt.plot(t[0:max_index], theoretical_lp_value_array[0:max_index], label = "Theoretical LP value") 152 | plt.plot(t[0:max_index], effective_lp_value_array[0:max_index], label = "Effective LP value") 153 | plt.title("Value of LP shares\n" + r"$\sigma = {vol}$, $K = {strike}$, $\gamma = {gam}$, $\tau_0 = {tau}$, $d\tau = {dt}$".format(vol=ANNUALIZED_VOL, strike=STRIKE_PRICE, gam=round(1-FEE, 3), dt=round(24*TIME_STEPS_SIZE*365), tau = TIME_TO_MATURITY)+" hours"+ ", np.seed("+str(SEED)+")") 154 | plt.xlabel("Time steps (years)") 155 | plt.ylabel("Value (USD)") 156 | plt.legend(loc='best') 157 | params_string = "sigma"+str(ANNUALIZED_VOL)+"_K"+str(STRIKE_PRICE)+"_gamma"+str(gamma)+"_dtau"+str(TAU_UPDATE_FREQUENCY)+"_seed"+str(SEED) 158 | filename = 'lp_value_'+params_string+'.svg' 159 | plt.plot() 160 | if SAVE_PAYOFF_EVOL: 161 | plt.savefig('sim_results/'+filename) 162 | plt.show(block = True) 163 | 164 | 165 | if PLOT_PAYOFF_DRIFT: 166 | plt.figure() 167 | plt.plot(t[0:max_index], 100*abs(theoretical_lp_value_array[max_index]-effective_lp_value_array[max_index])/theoretical_lp_value_array, label=f"Seed = {SEED}") 168 | plt.title("Drift of LP shares value vs. theoretical \n" + r"$\sigma = {vol}$, $K = {strike}$, $\gamma = {gam}$, $\tau_0 = {tau}$, $d\tau = {dt}$".format(vol=ANNUALIZED_VOL, strike=STRIKE_PRICE, gam=1-FEE, dt=TIME_STEPS_SIZE, tau = TIME_TO_MATURITY)+" days"+ ", np.seed("+str(SEED)+")") 169 | plt.xlabel("Time steps (years)") 170 | plt.ylabel("Drift (%)") 171 | plt.legend(loc='best') 172 | params_string = "sigma"+str(ANNUALIZED_VOL)+"_K"+str(STRIKE_PRICE)+"_gamma"+str(gamma)+"_dtau"+str(TAU_UPDATE_FREQUENCY)+"_seed"+str(SEED) 173 | filename = 'drift_seed_comparison'+params_string+'.svg' 174 | plt.plot() 175 | if SAVE_PAYOFF_DRIFT: 176 | plt.savefig('sim_results/'+filename) 177 | plt.show() 178 | 179 | # print("MSE = ", mse) 180 | # print("final divergence = ", 100*abs(theoretical_lp_value_array[-1] - effective_lp_value_array[-1])/theoretical_lp_value_array[-1], "%") -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from joblib.parallel import Parallel, delayed 3 | from scipy.stats import norm 4 | import matplotlib.pyplot as plt 5 | import os 6 | import json 7 | from datetime import datetime 8 | from pathlib import Path 9 | 10 | import numpy as np 11 | 12 | from modules.utils import generateGBM 13 | from modules.simulate import simulate 14 | from modules.optimize_fee import findOptimalFee 15 | from modules import cfmm 16 | from modules.utils import blackScholesCoveredCallSpotPrice 17 | from modules.cfmm import CoveredCallAMM 18 | import matplotlib.pyplot as plt 19 | from modules import arb 20 | 21 | def main(): 22 | # def getRisklessGivenRisky(risky, K, sigma, tau): 23 | # if risky == 0: 24 | # return K 25 | # elif risky == 1: 26 | # return 0 27 | # return K*norm.cdf(norm.ppf(1 - risky) - sigma*np.sqrt(tau)) 28 | 29 | # #Simulation parameters 30 | 31 | # #Annualized volatility 32 | # sigma = 0.50 33 | # sigma_str = str(sigma) 34 | 35 | # #Initial time to expiry 36 | # initial_tau = 1 37 | # #Strike price 38 | # K = 1100 39 | # #Fee 40 | # fee = 0 41 | # #Initial amount of the risky asset in the pool 42 | # initial_amount_risky = 0.5 43 | # #Generate AMM pool 44 | # Pool = cfmm.CoveredCallAMM(initial_amount_risky, K, sigma, initial_tau, fee) 45 | # #The current reserves in the pool 46 | # riskless_reserves = Pool.reserves_riskless 47 | 48 | # EPSILON = 1e-8 49 | 50 | # Pool.tau -= 1/365 51 | 52 | # print(blackScholesCoveredCallSpotPrice(0.5, 1100, 0.5, 1)) 53 | # def BSPriceSimplified(x, K, sigma, tau): 54 | # return (K*norm.pdf(norm.ppf(1 - x) - sigma*np.sqrt(tau)))/(norm.pdf(norm.ppf(1 - x))) 55 | 56 | # def furtherSimplified(x, K, sigma, tau): 57 | # return (K/(2*np.pi))*np.exp(sigma*np.sqrt(tau)*norm.ppf()) 58 | 59 | # print(BSPriceSimplified(0.5, 1100, 0.5, 1)) 60 | 61 | # print(BSPriceSimplified(0.000000001, 1100, 0.5, 1)) 62 | 63 | # TEST OF SPOT PRICE AT BOUNDARIES 64 | if False: 65 | # Annualized volatility 66 | sigma = 0.50 67 | # Initial time to expiry 68 | initial_tau = 1 69 | # Strike price 70 | K = 1100 71 | fee = 0 72 | initial_amount_risky = 0.5 73 | # Study the effect of kinks for small values of tau 74 | taus = [120/365, 60/365, 30/365] 75 | x = np.linspace(1e-15, 1-1e-15, 1000000) 76 | for tau in taus: 77 | y = blackScholesCoveredCallSpotPrice(x, Pool.K, Pool.sigma, tau) 78 | plt.plot(x, y, label=f"tau = {round(tau,2)}") 79 | plt.title("Reported price behavior for different values of tau \n" + r"$\sigma = {vol}$".format(vol=Pool.sigma) +" ; " r"$K = {strike}$".format(strike=K) + " ; " +r"$\gamma = {gam}$".format(gam=1 - fee)) 80 | plt.xlabel("Risky reserves (ETH)") 81 | plt.ylabel("Reported price (USD)") 82 | plt.legend(loc='best') 83 | plt.show(block = True) 84 | 85 | 86 | # Zoom in on kinks of the spot price curve 87 | taus = [0.3, 0.1, 0.05] 88 | for tau in taus: 89 | Pool.tau = tau 90 | x_near_zero = np.linspace(1e-10, 1e-16, 1000000) 91 | x_near_one = np.linspace(1-1e-10, 1-1e-16, 1000000) 92 | s_near_zero = blackScholesCoveredCallSpotPrice(x_near_zero, Pool.K, Pool.sigma, Pool.tau) 93 | s_near_one = blackScholesCoveredCallSpotPrice(x_near_one, Pool.K, Pool.sigma, Pool.tau) 94 | plt.plot(x_near_zero, s_near_zero) 95 | # plt.gca().invert_xaxis() 96 | plt.show(block = False) 97 | plt.figure() 98 | plt.plot(x_near_one, s_near_one) 99 | plt.show(block = True) 100 | 101 | # COMPARE ANALYTICAL TO FINITE DIFFERENCES MARGINAL PRICE 102 | # CALCULATIONS 103 | if False: 104 | x = np.linspace(0.0001, 0.3, 10) 105 | right, _ = Pool.virtualSwapAmountInRisky(x+EPSILON) 106 | left, _ = Pool.virtualSwapAmountInRisky(x) 107 | #RESULT IN USD PER ETH 108 | finite_difference = (right - left)/EPSILON 109 | analytical = Pool.getMarginalPriceSwapRiskyIn(x) 110 | print(finite_difference) 111 | print(analytical) 112 | 113 | if False: 114 | x = np.linspace(0.0001, 100, 10) 115 | # x = 0.000001 116 | right, _ = Pool.virtualSwapAmountInRiskless(x+EPSILON) 117 | left, effective_price = Pool.virtualSwapAmountInRiskless(x) 118 | #RESULT IN USD PER ETH 119 | finite_difference = EPSILON/(right-left) 120 | analytical = Pool.getMarginalPriceSwapRisklessIn(x) 121 | print(finite_difference) 122 | print(analytical) 123 | 124 | # TEST THAT THE AMOUNT OUT IS A CONCAVE FUNCTION 125 | # OF THE AMOUNT IN IN BOTH CASES 126 | if False: 127 | x = np.linspace(0.0001, 0.99999999*(1 - initial_amount_risky), 1000) 128 | y = np.linspace(0.0001, 0.99999999*(Pool.K - riskless_reserves), 1000) 129 | # x = np.linspace(0, 1e-4, 1000) 130 | # y = np.linspace(0, 1e-3, 1000) 131 | amounts_out_swap_risky_in, _ = Pool.virtualSwapAmountInRisky(x) 132 | amounts_out_swap_riskless_in, _ = Pool.virtualSwapAmountInRiskless(y) 133 | plt.plot(x, amounts_out_swap_risky_in) 134 | # plt.tight_layout() 135 | plt.title('Amount out riskless = f(amount in risky)') 136 | plt.show(block = False, ) 137 | plt.figure() 138 | plt.plot(y, amounts_out_swap_riskless_in) 139 | # plt.tight_layout() 140 | plt.ticklabel_format(axis="y", style="sci", scilimits=(0,0)) 141 | plt.title('Amount out risky = f(amount in riskless)') 142 | plt.show() 143 | 144 | #VIRTUAL SWAPS EFFECTIVE PRICE TESTS 145 | if False: 146 | _, effective_price_sell_risky = Pool.virtualSwapAmountInRisky(1e-8) 147 | _, effective_price_buy_risky = Pool.virtualSwapAmountInRiskless(1e-8) 148 | theoretical_price_sell = Pool.getMarginalPriceSwapRiskyIn(0) 149 | theoretical_price_buy = Pool.getMarginalPriceSwapRisklessIn(0) 150 | print(effective_price_sell_risky) 151 | print(effective_price_buy_risky) 152 | print(theoretical_price_sell) 153 | print(theoretical_price_buy) 154 | 155 | # CHECK THE EFFECT OF UPDATING K ON THE BUY AND SELL PRICES 156 | if False: 157 | #Annualized volatility 158 | sigma = 0.50 159 | #Initial time to expiry 160 | initial_tau = 1 161 | #Strike price 162 | K = 1100 163 | #Fee 164 | fee = 0.05 165 | #Initial amount of the risky asset in the pool 166 | initial_amount_risky = 0.5 167 | #Generate AMM pool 168 | Pool = cfmm.CoveredCallAMM(initial_amount_risky, K, sigma, initial_tau, fee) 169 | #The current reserves in the pool 170 | riskless_reserves = Pool.reserves_riskless 171 | EPSILON = 1e-8 172 | print("Before doing anything") 173 | print("Invariant = ", Pool.invariant) 174 | print("Max price: ", Pool.getMarginalPriceSwapRisklessIn(0)) 175 | print("Min price: ", Pool.getMarginalPriceSwapRiskyIn(0), "\n") 176 | 177 | Pool.tau -= 15/365 178 | print("Before updating k after the update in tau") 179 | print("Invariant = ", Pool.invariant) 180 | print("Max price: ", Pool.getMarginalPriceSwapRisklessIn(0)) 181 | print("Min price: ", Pool.getMarginalPriceSwapRiskyIn(0), "\n") 182 | Pool.invariant = Pool.reserves_riskless - getRisklessGivenRisky(Pool.reserves_risky, Pool.K, Pool.sigma, Pool.tau) 183 | print("After updating k after the update in tau") 184 | print("Invariant = ", Pool.invariant) 185 | print("Max price: ", Pool.getMarginalPriceSwapRisklessIn(0)) 186 | print("Min price: ", Pool.getMarginalPriceSwapRiskyIn(0), "\n") 187 | max_price = Pool.getMarginalPriceSwapRisklessIn(0) 188 | m = 1.1*max_price 189 | #Initialize arbitrager 190 | Arbitrager = arb.Arbitrager() 191 | Arbitrager.arbitrageExactly(m, Pool) 192 | print("After an arbitrage with m > max_price") 193 | print("Invariant = ", Pool.invariant) 194 | print("Max price: ", Pool.getMarginalPriceSwapRisklessIn(0)) 195 | print("Min price: ", Pool.getMarginalPriceSwapRiskyIn(0), "\n") 196 | 197 | # NEGATIVE RESERVES OCCURRENCES TEST 198 | if False: 199 | # Annualized volatility 200 | sigma = 0.50 201 | # Initial time to expiry 202 | initial_tau = 1 203 | # Strike price 204 | K = 1100 205 | fee = 0 206 | initial_amount_risky = 0.5 207 | # Initialize some arbitrary pool 208 | Pool = cfmm.CoveredCallAMM(initial_amount_risky, K, sigma, initial_tau, fee) 209 | # The parameters that cause an issue in the main routine 210 | Pool.tau = 0.5192307692307692 211 | Pool.invariant = -19.093097109440244 212 | Pool.reserves_risky = 0.9516935976350682 213 | Pool.reserves_riskless = 4.665850286101332 214 | 215 | reserves_risky = np.linspace(0.8, 1, 1000) 216 | 217 | # With zero invariant 218 | Pool.invariant = 0 219 | reserves_riskless = Pool.getRisklessGivenRisky(reserves_risky) 220 | plt.plot(reserves_risky, reserves_riskless, label = "With invariant = 0") 221 | 222 | # With "correct" invariant with respect to the original state of the pool 223 | Pool.invariant = -19.093097109440244 224 | reserves_riskless = Pool.getRisklessGivenRisky(reserves_risky) 225 | plt.plot(reserves_risky, reserves_riskless, label = "With 'valid' invariant for the current tau") 226 | 227 | plt.title("Negative invariant causing negative reserves \n" + r"$\sigma = {vol}$".format(vol=Pool.sigma) +" ; " + r"$K = {strike}$".format(strike=Pool.K) + " ; " +r"$\gamma = {gam}$".format(gam=1 - Pool.fee) +"\n" + r"$\tau = {tau}$".format(tau=Pool.tau) + "\n" + r"Initial $\tau = 1$") 228 | plt.xlabel("x") 229 | plt.ylabel("y") 230 | plt.legend(loc='best') 231 | plt.tight_layout() 232 | plt.show() 233 | 234 | # optimal_trade = 0.011221844928059747 235 | # Pool.swapAmountInRisky(optimal_trade) 236 | 237 | #INVARIANT CHANGE TEST 238 | if False: 239 | K = 2100 240 | initial_tau = 0.165 241 | sigma = 1.5 242 | fee = 0 243 | Pool = cfmm.CoveredCallAMM(0.5, K, sigma, initial_tau, fee) 244 | print("Invariant before = ", Pool.invariant) 245 | new_invariant = Pool.reserves_riskless - Pool.getRisklessGivenRiskyNoInvariant(Pool.reserves_risky) 246 | print("Invariant after = ", Pool.invariant) 247 | 248 | #GBM generation tests 249 | if False: 250 | 251 | # The goal is to produce a sensible GBM over a 30 days time windows with an annualized volatility of 150% 252 | # and some arbitrary drift. Try to change the drift, the unit of volatility etc in such a way that it 253 | # produces something that makes sense. 254 | 255 | from modules import utils 256 | 257 | # Example 1: converted annualized vol to timestep vol, high drift 258 | # NOTE: the price is shooting up, completely unrealistic 259 | initial_price = 1100 260 | annualized_vol = 1.5 261 | drift = 0.00434077479319 262 | # Total duration of the GBM in days 263 | time_horizon = 30 264 | # Time steps size in days 265 | time_steps_size = 0.0138889 #20 minutes 266 | # Number of time steps in a year 267 | N_timesteps = 365/time_steps_size 268 | # Scaled down volatility from annualized volatility 269 | sigma_timesteps = annualized_vol/np.sqrt(N_timesteps) 270 | t, S = utils.generateGBM(time_horizon, drift, sigma_timesteps, initial_price, time_steps_size) 271 | plt.plot(t, S) 272 | plt.title("Example 1") 273 | plt.show() 274 | 275 | # # Example 2: converted annualized vol to vol, low drift 276 | # # NOTE: the price might as well stay constant, completely unrealistic 277 | drift = 0.0004 278 | t, S = utils.generateGBM(time_horizon, drift, sigma_timesteps, initial_price, time_steps_size) 279 | plt.plot(t, S) 280 | plt.title("Example 2") 281 | plt.show() 282 | 283 | # # Example 3: converted annualized vol to timestep vol, drift somewhere in the middle 284 | # #NOTE: completely unrealistic, just going up regularly 285 | drift = 0.004 286 | t, S = utils.generateGBM(time_horizon, drift, sigma_timesteps, initial_price, time_steps_size) 287 | plt.plot(t, S) 288 | plt.title("Example 3") 289 | plt.show() 290 | 291 | #Example 4: everything expressed in years, WORKING 292 | time_horizon = 1 293 | drift = 1 294 | time_steps_size = 0.0027397260274 295 | t, S = utils.generateGBM(time_horizon, drift, annualized_vol, initial_price, time_steps_size) 296 | plt.plot(t, S) 297 | plt.title("Example 4") 298 | plt.show() 299 | 300 | #Simulate and return errors tests 301 | if False: 302 | fee = 0.01 303 | strike = 2000 304 | initial_price = 0.8*2000 305 | volatility = 0.5 306 | drift = 0.5 307 | time_steps_size = 0.0027397260274 308 | time_horizon = 1 309 | initial_tau = 1 310 | # Pool = CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 311 | # t, gbm = generateGBM(time_horizon, drift, volatility, initial_price, time_steps_size) 312 | # from modules.simulate import simulate 313 | # mse, terminal_square_error = simulate(Pool, t, gbm) 314 | from modules.optimize_fee import returnErrors, findOptimalFee 315 | mse, terminal_deviation = returnErrors(fee, initial_tau, time_steps_size, time_horizon, volatility, drift, strike, initial_price) 316 | 317 | #Test max error from fee runtime 318 | if False: 319 | fee = 0.01 320 | strike = 2000 321 | initial_price = 0.8*2000 322 | volatility = 0.5 323 | drift = 0.5 324 | time_steps_size = 0.0027397260274 325 | time_horizon = 1 326 | initial_tau = 1 327 | # fee = findOptimalFee(initial_tau, time_steps_size, time_horizon, volatility, drift, strike, initial_price) 328 | # print(fee) 329 | from modules.optimize_fee import returnErrors 330 | def ErrorFromFee(fee): 331 | ''' 332 | Return the max of the average mse and average terminal square error from 100 333 | simulations with different price actions given these parameters 334 | ''' 335 | mse_array = [] 336 | square_terminal_error_array = [] 337 | for i in range(100): 338 | mse, square_terminal_error = returnErrors(fee, initial_tau, time_steps_size, time_horizon, volatility, drift, strike, strike*0.8) 339 | mse_array.append(mse) 340 | square_terminal_error_array.append(square_terminal_error) 341 | average_mse = np.mean(mse_array) 342 | average_square_terminal_error = np.mean(square_terminal_error_array) 343 | return max(average_mse, average_square_terminal_error) 344 | m = ErrorFromFee(0.01) 345 | 346 | # Test parallel processing 347 | if False: 348 | import time 349 | from modules.optimize_fee import returnErrors 350 | from joblib import Parallel, delayed, parallel_backend 351 | 352 | fee = 0.01 353 | strike = 2000 354 | initial_price = 0.8*2000 355 | volatility = 0.5 356 | drift = 0.5 357 | time_steps_size = 0.0027397260274 358 | time_horizon = 1 359 | initial_tau = 1 360 | # fee = findOptimalFee(initial_tau, time_steps_size, time_horizon, volatility, drift, strike, initial_price) 361 | # print(fee) 362 | 363 | def ErrorFromFeeJoblib(fee): 364 | ''' 365 | Return the max of the average mse and average terminal square error from 100 366 | simulations with different price actions given these parameters 367 | ''' 368 | with parallel_backend("loky", inner_max_num_threads=24): 369 | results = Parallel(n_jobs=-1, verbose=1)(delayed(returnErrors)(fee, initial_tau, time_steps_size, time_horizon, volatility, drift, strike, strike*0.8) for i in range(5)) 370 | # print('results = ', results) 371 | average_mse = np.mean([item[0] for item in results]) 372 | average_square_terminal_error = np.mean([item[1] for item in results]) 373 | return max(average_mse, average_square_terminal_error) 374 | 375 | def ErrorFromFee(fee): 376 | ''' 377 | Return the max of the average mse and average terminal square error from 100 378 | simulations with different price actions given these parameters 379 | ''' 380 | mse_array = [] 381 | square_terminal_error_array = [] 382 | for i in range(5): 383 | mse, square_terminal_error = returnErrors(fee, initial_tau, time_steps_size, time_horizon, volatility, drift, strike, strike*0.8) 384 | mse_array.append(mse) 385 | square_terminal_error_array.append(square_terminal_error) 386 | average_mse = np.mean(mse_array) 387 | average_square_terminal_error = np.mean(square_terminal_error_array) 388 | return max(average_mse, average_square_terminal_error) 389 | 390 | # fees = fee*np.ones(2) 391 | start = time.time() 392 | m = ErrorFromFee(0.01) 393 | end = time.time() 394 | print("Without joblib = ", end - start) 395 | start = time.time() 396 | m = ErrorFromFeeJoblib(0.01) 397 | end = time.time() 398 | print("With joblib = ", end - start) 399 | 400 | # def returnErrors(fee, initial_tau, timestep_size, time_horizon, volatility, drift, strike, initial_price): 401 | # ''' 402 | # Given some parameters and a gbm, return the errors under 403 | # optimal arbitrage for that gbm 404 | # ''' 405 | # t, gbm = generateGBM(time_horizon, drift, volatility, initial_price, timestep_size) 406 | # Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 407 | # _, _, mse, terminal_square_error = simulate(Pool, t, gbm) 408 | # return mse, terminal_square_error 409 | 410 | # start = time.time() 411 | # m = ErrorFromFee(0.01) 412 | # end = time.time() 413 | # print("Runtime without numba: ", end - start) 414 | # start = time.time() 415 | # m = ErrorFromFee(0.01) 416 | # end = time.time() 417 | # print("Runtime with numba (compilation included): ", end - start) 418 | # start = time.time() 419 | # m = ErrorFromFee(0.01) 420 | # end = time.time() 421 | # print("Runtime with numba (from cache): ", end - start) 422 | 423 | # Test nested parallel ordering joblib 424 | if False: 425 | from joblib import Parallel, delayed 426 | import numpy as np 427 | 428 | # 2D test 429 | 430 | # def func(x, y): 431 | # return x*y 432 | 433 | # params = [[1,2,3], [1, 2, 3]] 434 | 435 | # def map2DArrayTo1D(i, j): 436 | # return i*len(params[0])+j 437 | 438 | # result = Parallel(n_jobs=-1, verbose=1)(delayed(func)(x,y) for y in params[1] for x in params[0] ) 439 | # # original_shape = 440 | # print(result[map2DArrayTo1D(2,0)]) 441 | 442 | # 3D test 443 | def func(x, y, z): 444 | return x*y*z 445 | 446 | params = [[1,2,3], [1, 2, 3, 4], [1, 2]] 447 | 448 | params_set_universe = [[[0 for i in range(len(params[2]))] for j in range(len(params[1]))] for k in range(len(params[0]))] 449 | 450 | for i in range(len(params[0])-1): 451 | for j in range(len(params[1])-1): 452 | for k in range(len(params[2])-1): 453 | params_set_universe[i][j][k] = (params[0][i], params[1][j], params[2][k]) 454 | 455 | print(params_set_universe) 456 | 457 | def map3DArrayTo1D(i, j, k): 458 | return i + len(params[0])*(j + k*len(params[2])) 459 | 460 | #Returns result in row_major order 461 | result = Parallel(n_jobs=-1, verbose=1)(delayed(func)(x,y,z) for z in params[2]for y in params[1] for x in params[0] ) 462 | # original_shape = 463 | print(params_set_universe[0, 3, 0]) 464 | print(result[map3DArrayTo1D(0,3,0)]) 465 | 466 | if False: 467 | parameters = np.array([np.linspace(0.5, 1.5, 3), np.linspace(-2, 2, 3), np.linspace(0.8, 0.9, 3)]) 468 | optimal_fee_array = [0, 1,2,3,4,5,6,7,8,9,10] 469 | data = {} 470 | data['parameters'] = parameters.tolist() 471 | data['optimal_fees'] = optimal_fee_array 472 | now = datetime.now() 473 | dt_string = now.strftime("%d-%m-%Y_%H-%M-%S") 474 | filename = 'optimization_results_'+ dt_string + '.dat' 475 | Path('optimization').mkdir(parents=True, exist_ok=True) 476 | with open('optimization/'+filename, 'w+') as f: 477 | json.dump(data, f) 478 | 479 | # Nested parallel benchmark 480 | if False: 481 | 482 | import numpy as np 483 | from joblib import Parallel, delayed 484 | import time 485 | 486 | from modules.utils import generateGBM 487 | from modules import cfmm 488 | from modules.simulate import simulate 489 | 490 | fee = 0.01 491 | strike = 2000 492 | initial_price = 0.8*2000 493 | volatility = 0.8 494 | drift = 0.5 495 | time_steps_size = 0.0027397260274 496 | time_horizon = 1 497 | initial_tau = 1 498 | 499 | count = [0] 500 | 501 | def simulation(): 502 | t, gbm = generateGBM(time_horizon, drift, volatility, initial_price, time_steps_size) 503 | Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 504 | simulate(Pool, t, gbm) 505 | count[0]+=1 506 | 507 | def makeExp(x): 508 | return np.exp(x) 509 | 510 | def funcParallel(n): 511 | Parallel(n_jobs=-1, verbose=1, backend="loky")(delayed(simulation)() for i in range(n)) 512 | 513 | def funcNonParallel(n): 514 | for i in range(N_INNER_SIMULATIONS): 515 | simulation() 516 | 517 | N_INNER_SIMULATIONS = 2 518 | N_OUTER_SIMULATIONS = 2 519 | 520 | 521 | 522 | # Completely non-parallelized 523 | start_sequential = time.time() 524 | for i in range(N_OUTER_SIMULATIONS): 525 | funcNonParallel(N_INNER_SIMULATIONS) 526 | end_sequential = time.time() 527 | 528 | # Parent loop parallelized 529 | start_partial_parallelize = time.time() 530 | results = Parallel(n_jobs=-1, verbose = 0, backend='loky')(delayed(funcNonParallel)(N_INNER_SIMULATIONS) for i in range(N_OUTER_SIMULATIONS)) 531 | end_partial_parallelize = time.time() 532 | 533 | # Full parallelization 534 | start_nested = time.time() 535 | results = Parallel(n_jobs=-1, verbose = 0, backend='loky')(delayed(funcParallel)(N_INNER_SIMULATIONS) for i in range(N_OUTER_SIMULATIONS)) 536 | end_nested = time.time() 537 | 538 | print("Total number of simulations per method: ", count[0]/3) 539 | print("Without Joblib: ", end_sequential - start_sequential) 540 | print("With partial parallelization: ", end_partial_parallelize - start_partial_parallelize) 541 | print("With nested Joblib: ", end_nested - start_nested) 542 | 543 | # print(results) 544 | 545 | # print(result) 546 | 547 | #Simulation runtime test 548 | if True: 549 | import time 550 | from modules import utils 551 | from modules import cfmm 552 | from modules.simulate import simulate 553 | fee = 0.01 554 | strike = 2000 555 | initial_price = 0.8*2000 556 | volatility = 0.5 557 | drift = 0.5 558 | time_steps_size = 0.0027397260274 559 | time_horizon = 1 560 | initial_tau = 1 561 | total_time = 0 562 | Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 563 | for i in range(100): 564 | t, gbm = utils.generateGBM(time_horizon, drift, volatility, initial_price, time_steps_size) 565 | start = time.time() 566 | _, _, _, _ = simulate(Pool, t, gbm) 567 | end = time.time() 568 | total_time += end-start 569 | print("Average runtime: ", total_time/100) 570 | 571 | if False: 572 | from modules import utils 573 | print(utils.getRiskyGivenSpotPriceWithDelta(2000, 3000, 0.5, 1)) 574 | print(utils.getRiskyReservesGivenSpotPrice(2000, 3000, 0.5, 1)) 575 | 576 | if False: 577 | import numpy as np 578 | import time 579 | from modules.utils import generateGBM 580 | from modules import cfmm 581 | from modules.simulate import simulate 582 | fee = 0.05 583 | strike = 2000 584 | initial_price = 0.8*2000 585 | volatility = 0.5 586 | drift = 0.5 587 | time_steps_size = 0.0027397260274 588 | time_horizon = 1 589 | initial_tau = 1 590 | total_time = 0 591 | np.random.seed(15425) 592 | Pool = cfmm.CoveredCallAMM(0.5, strike, volatility, initial_tau, fee) 593 | t, gbm =generateGBM(time_horizon, drift, volatility, initial_price, time_steps_size) 594 | start = time.time() 595 | _, _, _, d = simulate(Pool, t, gbm) 596 | end = time.time() 597 | print("RUNTIME: ", end-start) 598 | print(d) 599 | 600 | if __name__ == '__main__': 601 | main() 602 | 603 | 604 | --------------------------------------------------------------------------------