diff --git a/contracts/meta-oracles/RSIOracle.sol b/contracts/meta-oracles/RSIOracle.sol new file mode 100644 index 0000000..10f0c2e --- /dev/null +++ b/contracts/meta-oracles/RSIOracle.sol @@ -0,0 +1,81 @@ +/* + Copyright 2019 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +pragma solidity 0.5.7; +pragma experimental "ABIEncoderV2"; + +import { SafeMath } from "openzeppelin-solidity/contracts/math/SafeMath.sol"; +import { ITimeSeriesFeed } from "./interfaces/ITimeSeriesFeed.sol"; +import { RSILibrary } from "./lib/RSILibrary.sol"; + + +/** + * @title RSIOracle + * @author Set Protocol + * + * Contract used calculate RSI of data points provided by other on-chain + * price feed and return to querying contract. + */ +contract RSIOracle { + + using SafeMath for uint256; + + /* ============ State Variables ============ */ + string public dataDescription; + ITimeSeriesFeed public timeSeriesFeedInstance; + + /* ============ Constructor ============ */ + + /* + * RSIOracle constructor. + * Contract used calculate RSI of data points provided by other on-chain + * price feed and return to querying contract. + * + * @param _timeSeriesFeed TimeSeriesFeed to get list of data from + * @param _dataDescription Description of data + */ + constructor( + ITimeSeriesFeed _timeSeriesFeed, + string memory _dataDescription + ) + public + { + timeSeriesFeedInstance = _timeSeriesFeed; + + dataDescription = _dataDescription; + } + + /* + * Get RSI over defined amount of data points by querying price feed and + * calculating using RSILibrary. Returns uint256. + * + * @param _rsiTimePeriod RSI lookback period + * @returns RSI value for passed number of _rsiTimePeriod + */ + function read( + uint256 _rsiTimePeriod + ) + external + view + returns (uint256) + { + // Get data from price feed. This will be +1 the lookback period + uint256[] memory dataArray = timeSeriesFeedInstance.read(_rsiTimePeriod.add(1)); + + // Return RSI calculation + return RSILibrary.calculate(dataArray); + } +} \ No newline at end of file diff --git a/contracts/meta-oracles/lib/RSILibrary.sol b/contracts/meta-oracles/lib/RSILibrary.sol new file mode 100644 index 0000000..6e7db90 --- /dev/null +++ b/contracts/meta-oracles/lib/RSILibrary.sol @@ -0,0 +1,99 @@ +/* + Copyright 2019 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +pragma solidity 0.5.7; + +import { SafeMath } from "openzeppelin-solidity/contracts/math/SafeMath.sol"; + + +/** + * @title RSILibrary + * @author Set Protocol + * + * Library for calculating the Relative Strength Index + * + */ +library RSILibrary{ + + using SafeMath for uint256; + + /* ============ Constants ============ */ + + uint256 constant HUNDRED = 100; + + /* + * Calculates the new relative strength index value using + * an array of prices. + * + * RSI = 100 − 100 / + * (1 + (Average Gain / Average Loss) + * + * Price Difference = Price(N) - Price(N-1) where N is number of days + * Average Gain = Sum(Positive Price Difference) / N + * Average Loss = -1 * Sum(Negative Price Difference) / N + * + * + * Our implementation is simplified to the following for efficiency + * RSI = 100 - (100 * SUM(Loss) / ((SUM(Loss) + SUM(Gain))) + * + * + * @param _dataArray Array of prices used to calculate the RSI + * @returns The RSI value + */ + function calculate( + uint256[] memory _dataArray + ) + internal + view + returns (uint256) + { + uint256 positiveDataSum = 0; + uint256 negativeDataSum = 0; + + + // Check that data points must be greater than 1 + require( + _dataArray.length > 1, + "RSILibrary.calculate: Length of data array must be greater than 1" + ); + + // Sum negative and positive price differences + for (uint256 i = 1; i < _dataArray.length; i++) { + uint256 currentPrice = _dataArray[i - 1]; + uint256 previousPrice = _dataArray[i]; + if (currentPrice > previousPrice) { + positiveDataSum = positiveDataSum.add(currentPrice).sub(previousPrice); + } else { + negativeDataSum = negativeDataSum.add(previousPrice).sub(currentPrice); + } + } + + // Check that there must be a positive or negative price change + require( + negativeDataSum > 0 || positiveDataSum > 0, + "RSILibrary.calculate: Not valid RSI Value" + ); + + // a = 100 * SUM(Loss) + uint256 a = HUNDRED.mul(negativeDataSum); + // b = SUM(Gain) + SUM(Loss) + uint256 b = positiveDataSum.add(negativeDataSum); + // c = a / b + uint256 c = a.div(b); + + return HUNDRED.sub(c); + } +} \ No newline at end of file diff --git a/contracts/mocks/oracles/RSILibraryMock.sol b/contracts/mocks/oracles/RSILibraryMock.sol new file mode 100644 index 0000000..ce07add --- /dev/null +++ b/contracts/mocks/oracles/RSILibraryMock.sol @@ -0,0 +1,41 @@ +/* + Copyright 2019 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +pragma solidity 0.5.7; + +import { RSILibrary } from "../../meta-oracles/lib/RSILibrary.sol"; + +/** + * @title RSILibraryMock + * @author Set Protocol + * + * Mock contract for interacting with RSILibrary + */ +contract RSILibraryMock { + + /* ============ External Function ============ */ + + function calculateMock( + uint256[] calldata _dataArray + ) + external + returns (uint256) + { + return RSILibrary.calculate( + _dataArray + ); + } +} \ No newline at end of file diff --git a/test/contracts/oracles/lib/rsiLibrary.spec.ts b/test/contracts/oracles/lib/rsiLibrary.spec.ts new file mode 100644 index 0000000..49344ce --- /dev/null +++ b/test/contracts/oracles/lib/rsiLibrary.spec.ts @@ -0,0 +1,132 @@ +require('module-alias/register'); + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import { BigNumber } from 'bignumber.js'; + +import ChaiSetup from '@utils/chaiSetup'; +import { BigNumberSetup } from '@utils/bigNumberSetup'; +import { + RSILibraryMockContract, +} from '@utils/contracts'; +import { Blockchain } from '@utils/blockchain'; +import { ZERO } from '@utils/constants'; +import { expectRevertError } from '@utils/tokenAssertions'; +import { ether } from '@utils/units'; +import { getWeb3 } from '@utils/web3Helper'; + +import { LibraryMockHelper } from '@utils/helpers/libraryMockHelper'; +import { OracleHelper } from '@utils/helpers/oracleHelper'; + +BigNumberSetup.configure(); +ChaiSetup.configure(); +const web3 = getWeb3(); +const { expect } = chai; +const blockchain = new Blockchain(web3); + +contract('RSILibrary', accounts => { + const [ + deployerAccount, + ] = accounts; + + let rsiLibraryMock: RSILibraryMockContract; + + const libraryMockHelper = new LibraryMockHelper(deployerAccount); + const oracleHelper = new OracleHelper(deployerAccount); + + beforeEach(async () => { + blockchain.saveSnapshotAsync(); + + rsiLibraryMock = await libraryMockHelper.deployRSILibraryMockAsync(); + }); + + afterEach(async () => { + blockchain.revertAsync(); + }); + + describe('#calculate', async () => { + let subjectTimePeriod: number; + let subjectSeededValues: BigNumber[]; + + let customTimePeriod: number; + let customSeededValues: BigNumber[]; + + beforeEach(async () => { + subjectTimePeriod = customTimePeriod || 14; + subjectSeededValues = customSeededValues || + Array.from({length: subjectTimePeriod}, () => ether(Math.floor(Math.random() * 100) + 100)); + }); + + afterEach(async () => { + customTimePeriod = undefined; + customSeededValues = undefined; + }); + + async function subject(): Promise { + return rsiLibraryMock.calculateMock.callAsync( + subjectSeededValues, + ); + } + + it('returns the correct RSI value', async () => { + const output = await subject(); + const expectedOutput = oracleHelper.calculateRSI( + subjectSeededValues, + ); + expect(output).to.be.bignumber.equal(expectedOutput); + }); + + describe('using custom seeded values', async () => { + before(async () => { + customSeededValues = [ether(1.5), ether(2), ether(1.5), ether(3)]; + }); + + it('returns the correct RSI value', async () => { + const output = await subject(); + expect(output).to.be.bignumber.equal(20); + }); + }); + + describe('using custom seeded values where prices keep declining', async () => { + before(async () => { + customSeededValues = [ether(1.143), ether(1.243), ether(1.343)]; + }); + + it('returns the correct RSI value of 0', async () => { + const output = await subject(); + expect(output).to.be.bignumber.equal(ZERO); + }); + }); + + describe('using custom seeded values where prices keep rising', async () => { + before(async () => { + customSeededValues = [ether(1.643), ether(1.642), ether(1.641), ether(1.640), ether(1.639)]; + }); + + it('returns the correct RSI value of 100', async () => { + const output = await subject(); + expect(output).to.be.bignumber.equal(100); + }); + }); + + describe('using custom seeded values where prices are the same', async () => { + before(async () => { + customSeededValues = [ether(1.643), ether(1.643), ether(1.643), ether(1.643), ether(1.643), ether(1.643)]; + }); + + it('should revert', async () => { + await expectRevertError(subject()); + }); + }); + + describe('using only one seeded value', async () => { + before(async () => { + customSeededValues = [ether(1.643)]; + }); + + it('should revert', async () => { + await expectRevertError(subject()); + }); + }); + }); +}); diff --git a/test/contracts/oracles/rsiOracle.spec.ts b/test/contracts/oracles/rsiOracle.spec.ts new file mode 100644 index 0000000..0e72def --- /dev/null +++ b/test/contracts/oracles/rsiOracle.spec.ts @@ -0,0 +1,162 @@ +require('module-alias/register'); + +import * as _ from 'lodash'; +import * as chai from 'chai'; + +import { Address } from 'set-protocol-utils'; +import { BigNumber } from 'bignumber.js'; + +import ChaiSetup from '@utils/chaiSetup'; +import { BigNumberSetup } from '@utils/bigNumberSetup'; +import { Blockchain } from '@utils/blockchain'; +import { ether } from '@utils/units'; +import { MedianContract } from 'set-protocol-contracts'; +import { + LegacyMakerOracleAdapterContract, + LinearizedPriceDataSourceContract, + RSIOracleContract, + OracleProxyContract, + TimeSeriesFeedContract +} from '@utils/contracts'; +import { ONE_DAY_IN_SECONDS } from '@utils/constants'; +import { getWeb3 } from '@utils/web3Helper'; + +import { OracleHelper } from '@utils/helpers/oracleHelper'; + +BigNumberSetup.configure(); +ChaiSetup.configure(); +const web3 = getWeb3(); +const { expect } = chai; +const blockchain = new Blockchain(web3); + +contract('rsiOracle', accounts => { + const [ + deployerAccount, + ] = accounts; + + let ethMedianizer: MedianContract; + let legacyMakerOracleAdapter: LegacyMakerOracleAdapterContract; + let oracleProxy: OracleProxyContract; + let linearizedDataSource: LinearizedPriceDataSourceContract; + let timeSeriesFeed: TimeSeriesFeedContract; + let rsiOracle: RSIOracleContract; + + let initialEthPrice: BigNumber; + + const oracleHelper = new OracleHelper(deployerAccount); + + + beforeEach(async () => { + blockchain.saveSnapshotAsync(); + + ethMedianizer = await oracleHelper.deployMedianizerAsync(); + await oracleHelper.addPriceFeedOwnerToMedianizer(ethMedianizer, deployerAccount); + + legacyMakerOracleAdapter = await oracleHelper.deployLegacyMakerOracleAdapterAsync( + ethMedianizer.address, + ); + + oracleProxy = await oracleHelper.deployOracleProxyAsync( + legacyMakerOracleAdapter.address, + ); + + const interpolationThreshold = ONE_DAY_IN_SECONDS; + linearizedDataSource = await oracleHelper.deployLinearizedPriceDataSourceAsync( + oracleProxy.address, + interpolationThreshold, + ); + + await oracleHelper.addAuthorizedAddressesToOracleProxy( + oracleProxy, + [linearizedDataSource.address] + ); + + initialEthPrice = ether(150); + const seededValues = [initialEthPrice]; + timeSeriesFeed = await oracleHelper.deployTimeSeriesFeedAsync( + linearizedDataSource.address, + seededValues + ); + }); + + afterEach(async () => { + blockchain.revertAsync(); + }); + + describe('#constructor', async () => { + let subjectTimeSeriesFeedAddress: Address; + let subjectDataDescription: string; + + beforeEach(async () => { + subjectTimeSeriesFeedAddress = timeSeriesFeed.address; + subjectDataDescription = 'ETHDailyRSI'; + }); + + async function subject(): Promise { + return oracleHelper.deployRSIOracleAsync( + subjectTimeSeriesFeedAddress, + subjectDataDescription + ); + } + + it('sets the correct time series feed address', async () => { + rsiOracle = await subject(); + + const actualPriceFeedAddress = await rsiOracle.timeSeriesFeedInstance.callAsync(); + + expect(actualPriceFeedAddress).to.equal(subjectTimeSeriesFeedAddress); + }); + + it('sets the correct data description', async () => { + rsiOracle = await subject(); + + const actualDataDescription = await rsiOracle.dataDescription.callAsync(); + + expect(actualDataDescription).to.equal(subjectDataDescription); + }); + }); + + describe('#read', async () => { + let rsiTimePeriod: number; + let updatedValues: BigNumber[]; + + let subjectRSITimePeriod: BigNumber; + + beforeEach(async () => { + rsiTimePeriod = 14; + const updatedDataPoints = rsiTimePeriod + 1; // n + 1 data points needed for n period RSI + const updatedValuesReversed = await oracleHelper.batchUpdateTimeSeriesFeedAsync( + timeSeriesFeed, + ethMedianizer, + updatedDataPoints, + ); + + // Most recent daily price is first + updatedValues = updatedValuesReversed.reverse(); + + const dataDescription = 'ETHDailyRSI'; + rsiOracle = await oracleHelper.deployRSIOracleAsync( + timeSeriesFeed.address, + dataDescription + ); + + subjectRSITimePeriod = new BigNumber(rsiTimePeriod); + }); + + async function subject(): Promise { + return rsiOracle.read.callAsync( + subjectRSITimePeriod + ); + } + + it('returns the correct RSI', async () => { + const actualRSI = await subject(); + + const expectedRSI = oracleHelper.calculateRSI( + updatedValues, + ); + + expect(actualRSI).to.be.bignumber.equal(expectedRSI); + }); + }); +}); \ No newline at end of file diff --git a/utils/contracts.ts b/utils/contracts.ts index c19ff78..8741497 100644 --- a/utils/contracts.ts +++ b/utils/contracts.ts @@ -26,6 +26,8 @@ export { OracleProxyCallerContract } from '../types/generated/oracle_proxy_calle export { OracleProxyContract } from '../types/generated/oracle_proxy'; export { PriceFeedContract } from '../types/generated/price_feed'; export { PriceFeedMockContract } from '../types/generated/price_feed_mock'; +export { RSILibraryMockContract } from '../types/generated/r_s_i_library_mock'; +export { RSIOracleContract } from '../types/generated/r_s_i_oracle'; export { TimeSeriesFeedContract } from '../types/generated/time_series_feed'; export { TimeSeriesFeedV2Contract } from '../types/generated/time_series_feed_v2'; export { TimeSeriesFeedV2MockContract } from '../types/generated/time_series_feed_v2_mock'; diff --git a/utils/helpers/libraryMockHelper.ts b/utils/helpers/libraryMockHelper.ts index 14064df..a4008bf 100644 --- a/utils/helpers/libraryMockHelper.ts +++ b/utils/helpers/libraryMockHelper.ts @@ -2,6 +2,7 @@ import { Address } from 'set-protocol-utils'; import { DataSourceLinearInterpolationLibraryMockContract, EMALibraryMockContract, + RSILibraryMockContract, FlexibleTimingManagerLibraryMockContract, LinkedListHelperMockContract, LinkedListLibraryMockContract, @@ -24,7 +25,7 @@ const LinkedListLibraryMockV3 = artifacts.require('LinkedListLibraryMockV3'); const ManagerLibraryMock = artifacts.require('ManagerLibraryMock'); const EMALibraryMock = artifacts.require('EMALibraryMock'); const PriceFeedMock = artifacts.require('PriceFeedMock'); - +const RSILibraryMock = artifacts.require('RSILibraryMock'); export class LibraryMockHelper { private _contractOwnerAddress: Address; @@ -153,4 +154,17 @@ export class LibraryMockHelper { { from }, ); } + + public async deployRSILibraryMockAsync( + from: Address = this._contractOwnerAddress + ): Promise { + const rsiLibraryMockContract = await RSILibraryMock.new( + { from }, + ); + + return new RSILibraryMockContract( + new web3.eth.Contract(rsiLibraryMockContract.abi, rsiLibraryMockContract.address), + { from }, + ); + } } diff --git a/utils/helpers/oracleHelper.ts b/utils/helpers/oracleHelper.ts index 490655d..2f787dc 100644 --- a/utils/helpers/oracleHelper.ts +++ b/utils/helpers/oracleHelper.ts @@ -19,6 +19,7 @@ import { OracleProxyCallerContract, OracleProxyContract, PriceFeedContract, + RSIOracleContract, TimeSeriesFeedContract, TimeSeriesFeedV2Contract, TimeSeriesFeedV2MockContract @@ -44,6 +45,7 @@ const MovingAverageOracle = artifacts.require('MovingAverageOracle'); const MovingAverageOracleV2 = artifacts.require('MovingAverageOracleV2'); const OracleProxy = artifacts.require('OracleProxy'); const OracleProxyCaller = artifacts.require('OracleProxyCaller'); +const RSIOracle = artifacts.require('RSIOracle'); const TimeSeriesFeed = artifacts.require('TimeSeriesFeed'); const TimeSeriesFeedV2Mock = artifacts.require('TimeSeriesFeedV2Mock'); @@ -320,6 +322,23 @@ export class OracleHelper { ); } + public async deployRSIOracleAsync( + timeSeriesFeedAddress: Address, + dataDescription: string, + from: Address = this._contractOwnerAddress + ): Promise { + const rsiOracle = await RSIOracle.new( + timeSeriesFeedAddress, + dataDescription, + { from }, + ); + + return new RSIOracleContract( + new web3.eth.Contract(rsiOracle.abi, rsiOracle.address), + { from }, + ); + } + /* ============ Transactions ============ */ public async addPriceFeedOwnerToMedianizer( @@ -538,4 +557,46 @@ export class OracleHelper { return a.plus(b).div(c).round(0, 3); } + + /* + * + * RSI = 100 − 100 / + * (1 + (Daily Average Gain / Daily Average Loss) + * + * Daily Price Difference = Price(N) - Price(N-1) where n is number of days + * Daily Average Gain = Sum(Positive Daily Price Difference) / n + * Daily Average Loss = -1 * Sum(Positive Daily Price Difference) / n + * + * Our implementation is simplified to the following for efficiency + * RSI = 100 - (100 * SUM(Loss) / ((SUM(Loss) + SUM(Gain))) + * + */ + + public calculateRSI( + rsiDataArray: BigNumber[], + ): BigNumber { + let positiveDataSum = new BigNumber(0); + let negativeDataSum = new BigNumber(0); + + for (let i = 1; i < rsiDataArray.length; i++) { + if (rsiDataArray[i - 1].gte(rsiDataArray[i])) { + positiveDataSum = positiveDataSum.add(rsiDataArray[i - 1]).sub(rsiDataArray[i]); + } + else { + negativeDataSum = negativeDataSum.add(rsiDataArray[i]).sub(rsiDataArray[i - 1]); + } + } + + if (negativeDataSum.eq(0) && positiveDataSum.eq(0)) { + negativeDataSum = new BigNumber(1); + } + + const bigHundred = new BigNumber(100); + + const a = bigHundred.mul(negativeDataSum); + const b = positiveDataSum.add(negativeDataSum); + const c = a.div(b).round(0, BigNumber.ROUND_DOWN); + + return bigHundred.sub(c); + } }