├── .editorconfig ├── .github ├── FUNDING.yml └── dependabot.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── application.example.yaml ├── build.gradle ├── buildspec.yml ├── codequality └── cve-suppressions.xml ├── docker-compose.yaml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── docker │ ├── codebuild │ │ └── Dockerfile │ └── local │ │ └── Dockerfile ├── java │ └── com │ │ └── agonyforge │ │ └── arbitrader │ │ ├── Arbitrader.java │ │ ├── DecimalConstants.java │ │ ├── Utils.java │ │ ├── config │ │ ├── ExchangeConfiguration.java │ │ ├── ExecutorConfig.java │ │ ├── FeeComputation.java │ │ ├── JsonConfiguration.java │ │ ├── MailConfiguration.java │ │ ├── NotificationConfiguration.java │ │ ├── PaperConfiguration.java │ │ └── TradingConfiguration.java │ │ ├── exception │ │ ├── MarginNotSupportedException.java │ │ └── OrderNotFoundException.java │ │ ├── logging │ │ ├── DiscordAppender.java │ │ ├── SlackAppender.java │ │ └── SpringContextSingleton.java │ │ └── service │ │ ├── ConditionService.java │ │ ├── ErrorCollectorService.java │ │ ├── ExchangeService.java │ │ ├── LiquidityException.java │ │ ├── NotificationService.java │ │ ├── NotificationServiceImpl.java │ │ ├── SpreadService.java │ │ ├── TickerService.java │ │ ├── TradingScheduler.java │ │ ├── TradingService.java │ │ ├── cache │ │ ├── ExchangeBalanceCache.java │ │ ├── ExchangeFeeCache.java │ │ └── OrderVolumeCache.java │ │ ├── event │ │ ├── TickerEventListener.java │ │ └── TickerEventPublisher.java │ │ ├── model │ │ ├── ActivePosition.java │ │ ├── ArbitrageLog.java │ │ ├── EntryTradeVolume.java │ │ ├── ExchangeFee.java │ │ ├── ExitTradeVolume.java │ │ ├── Spread.java │ │ ├── TickerEvent.java │ │ ├── TradeCombination.java │ │ └── TradeVolume.java │ │ ├── paper │ │ ├── PaperAccountService.java │ │ ├── PaperExchange.java │ │ ├── PaperStreamExchange.java │ │ └── PaperTradeService.java │ │ ├── telegram │ │ └── TelegramClient.java │ │ └── ticker │ │ ├── ParallelTickerStrategy.java │ │ ├── SingleCallTickerStrategy.java │ │ ├── StreamingTickerStrategy.java │ │ ├── TickerStrategy.java │ │ └── TickerStrategyProvider.java └── resources │ ├── application.yaml │ ├── banner.txt │ ├── discord-appender.xml │ ├── logback-spring.xml │ └── slack-appender.xml └── test └── java └── com └── agonyforge └── arbitrader ├── BaseTestCase.java ├── ExchangeBuilder.java ├── logging └── SpringContextSingletonTest.java └── service ├── ConditionServiceTest.java ├── ErrorCollectorServiceTest.java ├── ExchangeServiceTest.java ├── SpreadServiceTest.java ├── TickerServiceTest.java ├── TradingServiceTest.java ├── cache ├── ExchangeBalanceCacheTest.java ├── ExchangeFeeCacheTest.java └── OrderVolumeCacheTest.java ├── model ├── ActivePositionTest.java ├── TradeCombinationTest.java └── TradeVolumeTest.java ├── paper └── PaperTradeServiceTest.java └── ticker ├── ParallelTickerStrategyTest.java ├── SingleCallTickerStrategyTest.java └── StreamingTickerStrategyTest.java /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{java,gradle,css,ftl,xml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [*.{sql,yml,yaml}] 13 | charset = utf-8 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.{txt,env,md}] 18 | charset = utf-8 19 | trim_trailing_whitespace = false 20 | 21 | [Dockerfile] 22 | charset = utf-8 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: scionaltera 2 | ko_fi: scionaltera 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gradle 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | assignees: 9 | - scionaltera 10 | ignore: 11 | - dependency-name: org.knowm.xchange:xchange-bitstamp 12 | versions: 13 | - 5.0.6 14 | - 5.0.7 15 | - dependency-name: org.knowm.xchange:xchange-binance 16 | versions: 17 | - 5.0.6 18 | - 5.0.7 19 | - dependency-name: org.knowm.xchange:xchange-stream-kraken 20 | versions: 21 | - 5.0.6 22 | - 5.0.7 23 | - dependency-name: org.knowm.xchange:xchange-poloniex 24 | versions: 25 | - 5.0.6 26 | - 5.0.7 27 | - dependency-name: org.knowm.xchange:xchange-stream-coinbasepro 28 | versions: 29 | - 5.0.6 30 | - 5.0.7 31 | - dependency-name: org.knowm.xchange:xchange-quoine 32 | versions: 33 | - 5.0.6 34 | - 5.0.7 35 | - dependency-name: org.knowm.xchange:xchange-stream-gemini 36 | versions: 37 | - 5.0.6 38 | - 5.0.7 39 | - dependency-name: org.knowm.xchange:xchange-bitflyer 40 | versions: 41 | - 5.0.6 42 | - 5.0.7 43 | - dependency-name: org.knowm.xchange:xchange-cobinhood 44 | versions: 45 | - 5.0.6 46 | - 5.0.7 47 | - dependency-name: org.knowm.xchange:xchange-cexio 48 | versions: 49 | - 5.0.6 50 | - 5.0.7 51 | - dependency-name: org.owasp.dependencycheck 52 | versions: 53 | - 6.1.2 54 | - 6.1.3 55 | - 6.1.4 56 | - dependency-name: org.springframework.boot 57 | versions: 58 | - 2.4.3 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle/ 3 | .idea/ 4 | build/ 5 | out/ 6 | .arbitrader/ 7 | 8 | *.iml 9 | .env 10 | /application.yaml 11 | /arbitrader-state.json 12 | /blackout 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Arbitrader 2 | Thanks for your interest in contributing! 3 | 4 | First of all, please feel free to propose changes to any part of the Arbitrader project with a pull request. If you have questions or have found a bug, please open an issue. 5 | 6 | ## What Can I Contribute? 7 | 8 | ### Report Bugs, Suggest Features 9 | 10 | #### Before Opening an Issue 11 | Be sure to check the open issues first, to make sure there isn't one that covers the bug you have found. 12 | 13 | #### Writing a Good Bug Report 14 | * **Use a short, clear, descriptive title.** Keep it crisp and unique so it's easy to know what the problem is from reading the title. 15 | * **Describe the steps to reproduce** the problem. Include relevant parts of your configuration file (but **NOT** your API keys!) and your log messages that show any errors or stack traces. 16 | 17 | #### Writing a Good Feature Request 18 | * **Use a short, clear, descriptive title.** Keep it to a phrase that gives the gist of what your request is. 19 | * **Clearly explain the need** for your new feature. Why would it help? What problem does it solve? In what ways would it help you? 20 | * **Describe how it would work.** What does it do? Does it need any new configuration? 21 | 22 | #### After Opening an Issue 23 | Be prepared to discuss your issue, and be open to suggestions or alternatives. The project maintainer(s) reserve the right to accept or reject suggestions based on how they align with the project vision. 24 | 25 | ### Add Exchanges 26 | Testing new exchanges is always useful. If you have added a new exchange and it works for you, please consider submitting a pull request back to the project so others can use it too. Here's a quick checklist of what you need to do. 27 | 28 | #### How to Add a New Exchange 29 | 1. Add the new exchange dependency in `build.gradle`. 30 | 1. Add the configuration for the new exchange in `application.yaml`. 31 | 1. Compile, run and watch the bot make some trades using the new exchange. 32 | 1. Once you are confident that it is working correctly, submit a pull request. 33 | 34 | #### What Goes in the Pull Request 35 | 1. The new dependency in `build.gradle`. 36 | 1. An example configuration in `application.example.yaml` with good descriptions of any unusual parameters (e.g. the "passphrase" for Coinbase Pro). **DO NOT** include your API keys! 37 | 1. Any other code you had to change to make the exchange work. 38 | 39 | ### New Features 40 | Adding new features, fixing bugs and updating documentation is always appreciated. If you aren't sure what to work on, check the open issues and look for anything labeled "Good First Issue". Those tend to be fairly small, well understood and straightforward to implement. Anything labeled "Help Wanted" might be a little more complex but would be particularly useful to have done. Or, if there's something else you want to change about Arbitrader, go ahead and do it! 41 | 42 | When you've decided what you'd like to do you can fork the project, open a branch and start working on your changes. When it's ready, open a pull request. 43 | 44 | A good pull request will have the following elements. 45 | 46 | 1. Work in your own fork in a branch, not `master`. 47 | 1. Write a detailed explanation of the pull request when you create it. 48 | 1. Try to match the code style of the existing code as best as you can. 49 | 1. Include documentation if... 50 | * You added a new feature that has no existing documentation. 51 | * You changed behavior that is already documented, making the existing docs incorrect. 52 | 1. Include tests that cover the code you changed. 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Peter Keeler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arbitrader 2 | ![AWS CodeBuild](https://codebuild.us-west-2.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoiUnhycTV0MEFLb293K1Y0QlBjOUxESnBaWXM3V3BLMGhEU2Zjcm0yWHpnRGhFdmxYWm45M2dqVU1ZUjRSdldhR0NsUEYyWk0xVWJZUVZBUXhGZmJUZjhrPSIsIml2UGFyYW1ldGVyU3BlYyI6InNER0tuUnhKQ0pUMVhTUVAiLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=master) 3 | [![Discord](https://img.shields.io/discord/767482695323222036?logo=Discord)](https://discord.gg/ZYmG3Nw) 4 | [![Ko-fi Blog](https://img.shields.io/badge/Blog-Ko--fi-informational?logo=Ko-fi)](https://ko-fi.com/scionaltera) 5 | **A market neutral cryptocurrency trading bot.** 6 | 7 | ## What is it? 8 | Arbitrader is a program that finds trading opportunities between two different cryptocurrency exchanges and performs automatic low-risk trades. 9 | 10 | The software is MIT licensed, so you are free to use it as you wish. Please keep in mind that trading cryptocurrencies carries a lot of inherent risk, and unattended automated trading can add even more risk. The author(s) of Arbitrader are not responsible if you lose money using this bot. Please be patient and careful! 11 | 12 | ## How do I use it? 13 | Please see the [wiki](https://github.com/agonyforge/arbitrader/wiki) for detailed instructions on running and using Arbitrader. 14 | 15 | ## How can I help out? 16 | I'm trying to build up a little community around Arbitrader. We have a [Discord](https://discord.gg/ZYmG3Nw) server and a blog over at [Ko-fi](https://ko-fi.com/scionaltera). If you'd like to meet some of the people who work on it and use it, or if you'd like to provide some feedback or support, it would mean a lot to me if you'd drop in and say hello. 17 | 18 | Code contributions are always welcome! Please see [CONTRIBUTING.md](https://github.com/agonyforge/arbitrader/blob/master/CONTRIBUTING.md) for details on how to contribute. 19 | 20 | ## How does it work? 21 | The trading strategy itself is inspired by [Blackbird](https://github.com/butor/blackbird). Arbitrader uses none of Blackbird's code, but a lot of the algorithms are similar. 22 | 23 | Arbitrader strives to be "market neutral". That is to say, it enters a long position on one exchange and a short position on the other. Assuming the prices on both exchanges were exactly the same, the result would be that no matter how the markets moved, you could exit both positions simultaneously and break even (not considering fees). Market neutrality is very desirable in crypto because the markets tend to be so volatile. 24 | 25 | **So how does it make any money, if it's "market neutral"?** 26 | 27 | The reality is that markets don't always have the same prices. Arbitrader waits until one market is higher in price than the other before executing the trades. It enters a short position on the higher priced exchange and a long position on the lower exchange. As the prices converge or even reverse, one trade will generally make a profit and the other will lose a little. 28 | 29 | In the configuration, you can specify via the "spread" how much different the markets need to be to enter a trade and how much they need to converge to make up the profit you'd like. The default entry spread is 0.008, or 0.8%, meaning the two markets have to be 0.8% different from one another. The exit target is 0.005, or 0.5%, meaning the markets need to come back together by 0.5% before it will exit the trades. 30 | 31 | Please be aware that Arbitrader will add the exchange fees onto the exit target when it calculates the actual prices. The 0.5% is just the profit part. A typical exchange might add another 0.2% for each trade, so the actual exit target would be 0.5% + (0.2% * 4) = 1.3%. If you started at 0.8% that means the markets have to reverse one another by 0.5% before it will exit the trade. 32 | 33 | It is very easy to choose entry and exit targets that result in the bot never making trades, or worse, entering trades but never exiting them. The "right" values to use there are going to vary by which currencies you trade, which exchanges you use, and your personal ability to wait on the bot to do stuff. It is normal to need to experiment and adjust those values from time to time as market conditions change. 34 | 35 | **Wait, "short"? What is that?** 36 | 37 | Most exchanges let you take one currency, such as USD, and use it to buy another currency, like BTC. You have to have some USD in your account and you make a simple trade with someone for the BTC, and that's the end of your transaction. The bet you're making is that the value of BTC will go up, so that's what you want to be holding when it does. 38 | 39 | "Shorting" gives you the option to make the bet that BTC will drop in value, even when you don't hold any BTC in your account. 40 | 41 | It works like this: 42 | 43 | 1. You only have USD in your account, but you place an order to sell BTC for more USD. That's a "short" sale. 44 | 1. Behind the scenes, you are borrowing some BTC from the exchange and immediately selling it on the market for some USD. 45 | 1. When you close your trade you are buying back the BTC at the current market price and giving it back to the exchange plus a little interest. That closes your position and satisfies the loan you had with the exchange. 46 | 47 | If the BTC price went down, you don't need to spend all the USD to buy back the BTC and you get to keep the difference. 48 | 49 | If the BTC price went up, it will cost more to buy it back than what you made by selling the borrowed BTC in the first place. You have to contribute the difference out of your own funds so you can pay the BTC back to the exchange. 50 | 51 | **Just how much money are we talking about here?** 52 | 53 | The gains from each trade tend to be modest, because the prices across exchanges don't tend to vary very widely. The benefit is that the risk is so low. Each pair of trades has a very high probability of making a small profit, and there is almost no impact from big market shifts. 54 | 55 | Compare that to opening just a single position. Sure, if it goes your way then you can make a lot more profit. If it doesn't go your way, then you stand to lose just as much. This strategy is nice because you're no longer betting on whether the price will go up or down: you're betting that divergent prices will tend to converge, and that tends to be a much safer bet. 56 | 57 | **You're saying it can't lose, then?** 58 | 59 | Not quite. It is possible that two exchanges have prices that are always divergent from one another. If the exchange with the higher price is always higher and never dips below the second exchange's price then the bot could open a pair of trades and never be able to close it profitably. 60 | 61 | It is also possible to configure the "spread", or the amount of difference between exchange prices, too narrowly or for the exchange fees to cost too much. You might want to narrow the entry and exit spreads to make the bot trade more frequently, for example. In those cases you might see your trades close, but the sum of both trades would be negative and you would have lost money overall. 62 | 63 | A third scenario is if your short order runs out of *margin*. Short orders are borrowing from the exchange, so they require you to keep some percentage of USD in your account as collateral. If your trade stays open and goes too far against you, the exchange will force your trade to close and you will end up paying most of the money out of your account to cover the difference. It's harsh, but the alternative would be for them to leave your trade open and send you a bill for money above and beyond what's in your account! 64 | 65 | **I'd like to see an example, please.** 66 | 67 | Ok, let's say there are two exchanges. We have a USD balance in both, and the price of COIN is $1000.00 on both exchanges. There is no opportunity here yet, since there is no difference between the prices. We'll wait for the prices to change. 68 | 69 | ``` 70 | FakeCoins CryptoTrade 71 | COIN/USD: 1000.00 1000.00 72 | ``` 73 | 74 | Sooner or later, the prices diverge enough to catch the bot's attention. 75 | 76 | ``` 77 | FakeCoins CryptoTrade 78 | COIN/USD: 950.00 1050.00 79 | ``` 80 | 81 | Now we're talking. We'll place a short order for $1000 on CryptoTrade and a long order for $1000 on FakeCoins, because we expect the higher price to go down and the lower price to go up until they match. 82 | 83 | ``` 84 | FakeCoins CryptoTrade 85 | COIN/USD: 950.00 1050.00 86 | (bought 1.05 COIN for $1000) 87 | (sold 0.95 COIN for $1000) 88 | ``` 89 | 90 | Suddenly the markets both jump up by $500! Somebody is pumping a ton of money into COIN/USD! Due to our market neutral strategy, we gained money on FakeCoins and lost the same amount of money on CryptoTrade. If the price had dropped by $500 instead, the opposite would have happened. The two trades balance each other out, so everything is fine. 91 | 92 | ``` 93 | FakeCoins CryptoTrade 94 | COIN/USD: 1450.00 1550.00 95 | (long 1.05 COIN) 96 | (short 0.95 COIN) 97 | ``` 98 | 99 | Finally, the prices converge and we can exit the trade. 100 | 101 | ``` 102 | FakeCoins CryptoTrade 103 | COIN/USD: 1550.00 1550.00 104 | (sold 1.05 COIN @ $1550.00 for $1627.50) 105 | (bought 0.95 COIN @ 1550.00 for $1472.50) 106 | ``` 107 | 108 | So what happened? Did we win? 109 | 110 | ``` 111 | $1627.50 (sold) - $1000.00 (bought) = $627.50 profit on our long FakeCoins trade. 112 | $1000.00 (sold) - $1472.50 (bought) = -$472.50 loss on our short CryptoTrade trade. 113 | $627.50 - $472.50 = $155.00 total profit, minus any fees. 114 | ``` 115 | 116 | We survived an unexpected major market shift and came out ahead in the end. Not bad! 117 | 118 | Obviously this is a contrived example with fake numbers. Your mileage will vary, but hopefully that illustrates the principle at work in the strategy. If you still aren't convinced, Blackbird has an [issue thread](https://github.com/butor/blackbird/issues/100) that goes into a huge amount of detail and is well worth the read. 119 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.springframework.boot' version '2.7.1' 4 | id 'io.spring.dependency-management' version '1.0.12.RELEASE' 5 | id 'com.palantir.docker' version '0.33.0' 6 | id 'info.solidsoft.pitest' version '1.7.4' 7 | id 'org.owasp.dependencycheck' version '7.1.1' 8 | id 'org.kordamp.gradle.stats' version '0.2.2' 9 | } 10 | 11 | group 'com.agonyforge' 12 | version '0.13.4-SNAPSHOT' 13 | 14 | jar { 15 | enabled = false // don't build the plain, non-executable jar 16 | } 17 | 18 | bootJar { 19 | manifest { 20 | attributes('Implementation-Title': jar.getArchiveBaseName().get(), 21 | 'Implementation-Version': getArchiveVersion()) 22 | } 23 | } 24 | 25 | sourceCompatibility = 1.8 26 | targetCompatibility = 1.8 27 | 28 | repositories { 29 | mavenLocal() 30 | mavenCentral() 31 | } 32 | 33 | configurations.implementation { 34 | exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' 35 | } 36 | 37 | dependencies { 38 | implementation group: 'javax.inject', name: 'javax.inject', version: '1' 39 | 40 | implementation group: 'org.springframework.boot', name: 'spring-boot-starter' 41 | implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail' 42 | 43 | implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: '2.14.1' 44 | implementation group: 'com.squareup.okhttp3', name:'okhttp', version:'4.10.0' 45 | 46 | implementation group: 'org.knowm.xchange', name: 'xchange-quoine', version: '5.0.14' 47 | implementation group: 'org.knowm.xchange', name: 'xchange-stream-kraken', version: '5.0.14' 48 | implementation group: 'org.knowm.xchange', name: 'xchange-bitflyer', version: '5.0.14' 49 | implementation group: 'org.knowm.xchange', name: 'xchange-stream-coinbasepro', version: '5.0.14' 50 | implementation group: 'org.knowm.xchange', name: 'xchange-cexio', version: '5.0.14' 51 | implementation group: 'org.knowm.xchange', name: 'xchange-poloniex', version: '5.0.14' 52 | implementation group: 'org.knowm.xchange', name: 'xchange-stream-gemini', version: '5.0.14' 53 | implementation group: 'org.knowm.xchange', name: 'xchange-bitstamp', version: '5.0.14' 54 | implementation group: 'org.knowm.xchange', name: 'xchange-cobinhood', version: '5.0.14' 55 | implementation group: 'org.knowm.xchange', name: 'xchange-binance', version: '5.0.14' 56 | 57 | implementation group: 'com.github.seratch', name: 'jslack', version: '3.4.2' 58 | implementation group: 'javax.websocket', name: 'javax.websocket-api', version: '1.1' 59 | 60 | implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.4' 61 | implementation group: 'org.apache.commons', name: 'commons-text', version: '1.10.0' 62 | implementation group: 'commons-io', name: 'commons-io', version: '2.11.0' 63 | 64 | testImplementation group: 'junit', name: 'junit', version: '4.13.2' 65 | testImplementation group: 'org.mockito', name: 'mockito-core', version: '4.6.1' 66 | testImplementation group: 'org.pitest', name: 'pitest', version: '1.9.3' 67 | } 68 | 69 | docker { 70 | name 'agonyforge/arbitrader' 71 | dockerfile file('src/main/docker/local/Dockerfile') 72 | files tasks.bootJar.outputs 73 | } 74 | 75 | project.tasks.build.dependsOn tasks.docker 76 | 77 | pitest { 78 | pitestVersion.set('1.6.6') 79 | excludedMethods.set([ 80 | 'hashCode', 81 | 'equals', 82 | 'toString' 83 | ]) 84 | timestampedReports.set false 85 | } 86 | 87 | dependencyCheck { 88 | suppressionFile "codequality/cve-suppressions.xml" 89 | } 90 | 91 | check.dependsOn project.tasks.pitest, project.tasks.dependencyCheckAnalyze 92 | 93 | project.tasks.pitest.mustRunAfter test 94 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | parameter-store: 5 | DOCKER_HUB_USERNAME: "docker-hub-username" 6 | DOCKER_HUB_PASSWORD: "docker-hub-password" 7 | GITHUB_USERNAME: "agonyforge-github-packages-username" 8 | GITHUB_TOKEN: "agonyforge-github-packages-token" 9 | 10 | phases: 11 | install: 12 | commands: 13 | - chmod +x ./gradlew 14 | pre_build: 15 | commands: 16 | - export PROJECT_VERSION=v`egrep "^version" build.gradle | cut -f2 -d\'` 17 | - export BRANCH_TAG=`echo $CODEBUILD_SOURCE_VERSION | sed 's|/|-|g'` 18 | - echo Project version is ${PROJECT_VERSION} 19 | - echo Branch tag is ${BRANCH_TAG} 20 | build: 21 | commands: 22 | - docker login -u ${DOCKER_HUB_USERNAME} -p ${DOCKER_HUB_PASSWORD} 23 | - docker login -u ${GITHUB_USERNAME} -p ${GITHUB_TOKEN} https://ghcr.io 24 | - docker build -t agonyforge/arbitrader:latest -f src/main/docker/codebuild/Dockerfile . 25 | post_build: 26 | commands: 27 | - echo "CodeBuild Initiator is ${CODEBUILD_INITIATOR}" 28 | - | 29 | if expr "${CODEBUILD_INITIATOR}" : "codepipeline*" >/dev/null; then 30 | docker tag agonyforge/arbitrader:latest agonyforge/arbitrader:latest 31 | docker tag agonyforge/arbitrader:latest agonyforge/arbitrader:${PROJECT_VERSION} 32 | docker tag agonyforge/arbitrader:latest ghcr.io/agonyforge/arbitrader:latest 33 | docker tag agonyforge/arbitrader:latest ghcr.io/agonyforge/arbitrader:${PROJECT_VERSION} 34 | docker push agonyforge/arbitrader:latest 35 | docker push agonyforge/arbitrader:${PROJECT_VERSION} 36 | docker push ghcr.io/agonyforge/arbitrader:latest 37 | docker push ghcr.io/agonyforge/arbitrader:${PROJECT_VERSION} 38 | elif expr "${CODEBUILD_INITIATOR}" : "GitHub*" >/dev/null; then 39 | docker tag agonyforge/arbitrader:latest agonyforge/arbitrader:${BRANCH_TAG} 40 | docker tag agonyforge/arbitrader:latest ghcr.io/agonyforge/arbitrader:${BRANCH_TAG} 41 | docker push agonyforge/arbitrader:${BRANCH_TAG} 42 | docker push ghcr.io/agonyforge/arbitrader:${BRANCH_TAG} 43 | else 44 | docker tag agonyforge/arbitrader:latest agonyforge/arbitrader:${BRANCH_TAG} 45 | docker tag agonyforge/arbitrader:latest ghcr.io/agonyforge/arbitrader:${BRANCH_TAG} 46 | docker push agonyforge/arbitrader:${BRANCH_TAG} 47 | docker push ghcr.io/agonyforge/arbitrader:${BRANCH_TAG} 48 | fi 49 | - printf '[{"name":"arbitrader","imageUri":"%s"}]' agonyforge/arbitrader:${PROJECT_VERSION} > imagedefinitions.json 50 | artifacts: 51 | files: imagedefinitions.json 52 | -------------------------------------------------------------------------------- /codequality/cve-suppressions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | arbitrader: 5 | image: agonyforge/arbitrader:latest 6 | tty: true 7 | ports: 8 | - "5005:5005" 9 | volumes: 10 | - ${PWD}/application.yaml:/application.yaml 11 | - ${PWD}/.arbitrader:/.arbitrader 12 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agonyforge/arbitrader/38df38685a62f90598d4bb1b8b645fd066c48947/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Dec 07 11:25:55 PST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'arbitrader' 2 | 3 | -------------------------------------------------------------------------------- /src/main/docker/codebuild/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM amazoncorretto:8-alpine as build 2 | MAINTAINER Peter Keeler 3 | WORKDIR /opt/build 4 | COPY . /opt/build/ 5 | RUN cd /opt/build \ 6 | && apk update \ 7 | && apk add --no-cache bash \ 8 | && ./gradlew --console=plain clean build -x docker -x dependencyCheckAnalyze 9 | 10 | FROM amazoncorretto:8-alpine 11 | MAINTAINER Peter Keeler 12 | LABEL org.opencontainers.image.source="https://github.com/agonyforge/arbitrader" 13 | EXPOSE 8080 14 | COPY --from=build /opt/build/build/libs/arbitrader-*.jar /opt/app/app.jar 15 | CMD ["/usr/bin/java", "-jar", "/opt/app/app.jar"] 16 | -------------------------------------------------------------------------------- /src/main/docker/local/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM amazoncorretto:8-alpine 2 | MAINTAINER Peter Keeler 3 | LABEL org.opencontainers.image.source="https://github.com/agonyforge/arbitrader" 4 | EXPOSE 8080 5 | COPY arbitrader-*.jar /opt/app/app.jar 6 | CMD ["/usr/bin/java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005", "-jar", "/opt/app/app.jar"] 7 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/Arbitrader.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.scheduling.annotation.EnableAsync; 6 | import org.springframework.scheduling.annotation.EnableScheduling; 7 | 8 | @EnableAsync 9 | @EnableScheduling 10 | @SpringBootApplication 11 | public class Arbitrader { 12 | public static void main(String... args) { 13 | SpringApplication.run(Arbitrader.class, args); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/DecimalConstants.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader; 2 | 3 | public final class DecimalConstants { 4 | public static final int USD_SCALE = 2; 5 | public static final int BTC_SCALE = 8; 6 | 7 | private DecimalConstants() { 8 | // this method intentionally left blank 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/Utils.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader; 2 | 3 | import info.bitrich.xchangestream.core.StreamingExchange; 4 | import org.apache.commons.io.FileUtils; 5 | import org.knowm.xchange.Exchange; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | 9 | import java.io.File; 10 | import java.io.IOException; 11 | import java.nio.charset.Charset; 12 | import java.util.Arrays; 13 | 14 | /** 15 | * A few utilities to make life easier. 16 | */ 17 | public final class Utils { 18 | private static final Logger LOGGER = LoggerFactory.getLogger(Utils.class); 19 | 20 | public static final String STATE_FILE = ".arbitrader/arbitrader-state.json"; 21 | 22 | // Intentionally empty 23 | private Utils() {} 24 | 25 | /** 26 | * Load an exchange class by its name. This used to be handled within XChange but was deprecated. 27 | * 28 | * @param clazzName The fully qualified name of the class to load. 29 | * @return A Class that is an Exchange. 30 | * @throws ClassNotFoundException when the class cannot be found. 31 | */ 32 | public static Class loadExchangeClass(String clazzName) throws ClassNotFoundException { 33 | Class clazz = Class.forName(clazzName); // loads the class into the JVM or throws 34 | Class superclazz = clazz; 35 | 36 | // walk up each of the superclasses of our class and inspect each of them to see whether any of them implements Exchange 37 | do { 38 | superclazz = superclazz.getSuperclass(); 39 | } while (superclazz != null && Arrays.stream(superclazz.getInterfaces()).noneMatch(i -> i.equals(Exchange.class))); 40 | 41 | // if none of the classes implements Exchange, we can't use it 42 | if (superclazz == null) { 43 | throw new ClassNotFoundException(String.format("Class %s must extend Exchange", clazzName)); 44 | } 45 | 46 | LOGGER.info("Loaded exchange class: {}", clazz.getCanonicalName()); 47 | 48 | return clazz.asSubclass(Exchange.class); 49 | } 50 | 51 | /** 52 | * Is this Exchange a StreamingExchange? 53 | * 54 | * @param exchange The Exchange to inspect. 55 | * @return true if the Exchange is also an instance of StreamingExchange 56 | */ 57 | public static boolean isStreamingExchange(Exchange exchange) { 58 | return exchange instanceof StreamingExchange; 59 | } 60 | 61 | /** 62 | * Create a local file with the current bot state. 63 | * @param state the state to save 64 | * @throws IOException 65 | */ 66 | public static void createStateFile(String state) throws IOException { 67 | FileUtils.write(new File(STATE_FILE), state, Charset.defaultCharset()); 68 | } 69 | 70 | /** 71 | * Delete the state file. 72 | */ 73 | public static void deleteStateFile() { 74 | FileUtils.deleteQuietly(new File(STATE_FILE)); 75 | } 76 | 77 | /** 78 | * Check whether the state file exists or not. 79 | * @return true if the state file could be found otherwise false. 80 | */ 81 | public static boolean stateFileExists() { 82 | return new File(STATE_FILE).exists(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/ExchangeConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import org.knowm.xchange.currency.Currency; 4 | import org.knowm.xchange.currency.CurrencyPair; 5 | 6 | import java.math.BigDecimal; 7 | import java.util.*; 8 | 9 | /** 10 | * This class contains any configuration from application.yaml that users can set to change the behavior of a 11 | * single exchange. An instance of this class is stored on the Exchange so the configuration can be accessed 12 | * at any time. 13 | */ 14 | public class ExchangeConfiguration { 15 | private String exchangeClass; 16 | private String userName; 17 | private String apiKey; 18 | private String secretKey; 19 | private String sslUri; 20 | private String host; 21 | private Integer port; 22 | private Map custom = new HashMap<>(); 23 | private List tradingPairs = new ArrayList<>(); 24 | private Map minBalanceOverride = new HashMap<>(); 25 | private Boolean margin; 26 | private List marginExclude = new ArrayList<>(); 27 | private BigDecimal tradeFee; 28 | private BigDecimal tradeFeeOverride; 29 | private BigDecimal marginFee; 30 | private BigDecimal marginFeeOverride; 31 | private Currency homeCurrency = Currency.USD; 32 | private Map ticker = new HashMap<>(); 33 | private List tickerArguments = new ArrayList<>(); 34 | private FeeComputation feeComputation = FeeComputation.SERVER; 35 | private Boolean active; 36 | 37 | public String getExchangeClass() { 38 | return exchangeClass; 39 | } 40 | 41 | public void setExchangeClass(String exchangeClass) { 42 | this.exchangeClass = exchangeClass; 43 | } 44 | 45 | public String getUserName() { 46 | return userName; 47 | } 48 | 49 | public void setUserName(String userName) { 50 | this.userName = userName; 51 | } 52 | 53 | public String getApiKey() { 54 | return apiKey; 55 | } 56 | 57 | public void setApiKey(String apiKey) { 58 | this.apiKey = apiKey; 59 | } 60 | 61 | public String getSecretKey() { 62 | return secretKey; 63 | } 64 | 65 | public void setSecretKey(String secretKey) { 66 | this.secretKey = secretKey; 67 | } 68 | 69 | public String getSslUri() { 70 | return sslUri; 71 | } 72 | 73 | public void setSslUri(String sslUri) { 74 | this.sslUri = sslUri; 75 | } 76 | 77 | public String getHost() { 78 | return host; 79 | } 80 | 81 | public void setHost(String host) { 82 | this.host = host; 83 | } 84 | 85 | public Integer getPort() { 86 | return port; 87 | } 88 | 89 | public void setPort(Integer port) { 90 | this.port = port; 91 | } 92 | 93 | public Map getCustom() { 94 | return custom; 95 | } 96 | 97 | public void setCustom(Map custom) { 98 | this.custom = custom; 99 | } 100 | 101 | public List getTradingPairs() { 102 | return tradingPairs; 103 | } 104 | 105 | public void setTradingPairs(List tradingPairs) { 106 | this.tradingPairs = tradingPairs; 107 | } 108 | 109 | public Optional getMinBalanceOverride(CurrencyPair currencyPair) { 110 | return Optional.ofNullable(minBalanceOverride.get(currencyPair.base.toString() + currencyPair.counter.toString())); 111 | } 112 | 113 | public void setMinBalanceOverride(Map minBalanceOverride) { 114 | this.minBalanceOverride = minBalanceOverride; 115 | } 116 | 117 | public Boolean getMargin() { 118 | return margin; 119 | } 120 | 121 | public void setMargin(Boolean margin) { 122 | this.margin = margin; 123 | } 124 | 125 | public List getMarginExclude() { 126 | return marginExclude; 127 | } 128 | 129 | public void setMarginExclude(List marginExclude) { 130 | this.marginExclude = marginExclude; 131 | } 132 | 133 | public BigDecimal getTradeFee() { 134 | return tradeFee; 135 | } 136 | 137 | public void setTradeFee(BigDecimal tradeFee) { 138 | this.tradeFee = tradeFee; 139 | } 140 | 141 | public BigDecimal getTradeFeeOverride() { 142 | return tradeFeeOverride; 143 | } 144 | 145 | public void setTradeFeeOverride(BigDecimal tradeFeeOverride) { 146 | this.tradeFeeOverride = tradeFeeOverride; 147 | } 148 | 149 | public Currency getHomeCurrency() { 150 | return homeCurrency; 151 | } 152 | 153 | public void setHomeCurrency(Currency homeCurrency) { 154 | this.homeCurrency = homeCurrency; 155 | } 156 | 157 | public Map getTicker() { 158 | return ticker; 159 | } 160 | 161 | public void setTicker(Map ticker) { 162 | this.ticker = ticker; 163 | } 164 | 165 | public List getTickerArguments() { 166 | return tickerArguments; 167 | } 168 | 169 | public void setTickerArguments(List tickerArguments) { 170 | this.tickerArguments = tickerArguments; 171 | } 172 | 173 | public FeeComputation getFeeComputation() { 174 | return feeComputation; 175 | } 176 | 177 | public void setFeeComputation(FeeComputation feeComputation) { 178 | this.feeComputation = feeComputation; 179 | } 180 | 181 | public Boolean getActive() { 182 | return active; 183 | } 184 | 185 | public void setActive(Boolean active) { 186 | this.active = active; 187 | } 188 | 189 | public BigDecimal getMarginFee() { 190 | return marginFee; 191 | } 192 | 193 | public void setMarginFee(BigDecimal marginFee) { 194 | this.marginFee = marginFee; 195 | } 196 | 197 | public BigDecimal getMarginFeeOverride() { 198 | return marginFeeOverride; 199 | } 200 | 201 | public void setMarginFeeOverride(BigDecimal marginFeeOverride) { 202 | this.marginFeeOverride = marginFeeOverride; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/ExecutorConfig.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 6 | 7 | import java.util.concurrent.Executor; 8 | 9 | /** 10 | * Configuration for the task executor. 11 | */ 12 | @Configuration 13 | public class ExecutorConfig { 14 | 15 | @Bean 16 | public Executor taskExecutor() { 17 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 18 | 19 | executor.setCorePoolSize(0); 20 | executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() + 1); 21 | // executor.setQueueCapacity(100); 22 | executor.setThreadNamePrefix("async-trade-pool-"); 23 | executor.initialize(); 24 | 25 | return executor; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/FeeComputation.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | /** 4 | * Are fees computed on the client or the server? 5 | */ 6 | public enum FeeComputation { 7 | SERVER, // fees are computed on the server 8 | CLIENT // fees must be accounted for by the client (that's us!) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/JsonConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.fasterxml.jackson.databind.SerializationFeature; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | /** 10 | * Sets some default configuration on the Jackson JSON parser. 11 | */ 12 | @Configuration 13 | public class JsonConfiguration { 14 | @Bean 15 | public ObjectMapper objectMapper() { 16 | ObjectMapper objectMapper = new ObjectMapper(); 17 | 18 | objectMapper.findAndRegisterModules(); 19 | objectMapper.configure(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE, false); 20 | objectMapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true); 21 | objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); 22 | 23 | return objectMapper; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/MailConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import com.agonyforge.arbitrader.service.NotificationService; 4 | import com.agonyforge.arbitrader.service.NotificationServiceImpl; 5 | import com.agonyforge.arbitrader.service.model.Spread; 6 | import com.agonyforge.arbitrader.service.model.EntryTradeVolume; 7 | import com.agonyforge.arbitrader.service.model.ExitTradeVolume; 8 | import com.agonyforge.arbitrader.service.telegram.TelegramClient; 9 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 11 | import org.springframework.boot.context.properties.ConfigurationProperties; 12 | import org.springframework.context.annotation.Bean; 13 | import org.springframework.context.annotation.Configuration; 14 | import org.springframework.mail.javamail.JavaMailSender; 15 | import org.springframework.mail.javamail.JavaMailSenderImpl; 16 | 17 | import java.math.BigDecimal; 18 | 19 | /** 20 | * Configuration for sending emails. 21 | */ 22 | @Configuration 23 | public class MailConfiguration { 24 | 25 | @Bean 26 | @ConditionalOnProperty(prefix = "spring", value = "mail") 27 | public NotificationService notificationService(JavaMailSender javaMailSender, NotificationConfiguration config, TelegramClient telegramClient) { 28 | return new NotificationServiceImpl(javaMailSender, config, telegramClient); 29 | } 30 | 31 | @Bean 32 | @ConditionalOnMissingBean(value = NotificationService.class) 33 | public NotificationService notificationService() { 34 | return new NotificationService() { 35 | @Override 36 | public void sendNotification(String subject, String message) { 37 | } 38 | 39 | @Override 40 | public void sendEntryTradeNotification(Spread spread, BigDecimal exitTarget, EntryTradeVolume tradeVolume, BigDecimal longLimitPrice, BigDecimal shortLimitPrice, boolean isForceEntryPosition) { 41 | } 42 | 43 | @Override 44 | public void sendExitTradeNotification(Spread spread, ExitTradeVolume tradeVolume, BigDecimal longLimitPrice, BigDecimal shortLimitPrice, BigDecimal entryBalance, BigDecimal updatedBalance, BigDecimal exitTarget, boolean isForceCloseCondition, boolean isActivePositionExpired) { 45 | } 46 | }; 47 | } 48 | 49 | @Bean 50 | @ConfigurationProperties(prefix = "spring.mail") 51 | @ConditionalOnMissingBean(value = JavaMailSender.class) 52 | public JavaMailSender javaMailSender() { 53 | return new JavaMailSenderImpl(); 54 | } 55 | 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/NotificationConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | /** 8 | * Configuration for notifications to other services like email, Slack and Discord. 9 | */ 10 | @ConfigurationProperties("notifications") 11 | @Configuration 12 | public class NotificationConfiguration { 13 | private Slack slack = new Slack(); 14 | private Logs logs = new Logs(); 15 | private Mail mail = new Mail(); 16 | private Discord discord = new Discord(); 17 | private Telegram telegram = new Telegram(); 18 | 19 | public Slack getSlack() { 20 | return slack; 21 | } 22 | 23 | public void setSlack(Slack slack) { 24 | this.slack = slack; 25 | } 26 | 27 | public Logs getLogs() { 28 | return logs; 29 | } 30 | 31 | public void setLogs(Logs logs) { 32 | this.logs = logs; 33 | } 34 | 35 | public Mail getMail() { 36 | return mail; 37 | } 38 | 39 | public void setMail(Mail mail) { 40 | this.mail = mail; 41 | } 42 | 43 | public Discord getDiscord() { 44 | return discord; 45 | } 46 | 47 | public void setDiscord(Discord discord) { 48 | this.discord = discord; 49 | } 50 | 51 | public Telegram getTelegram() { 52 | return telegram; 53 | } 54 | 55 | public void setTelegram(Telegram telegram) { 56 | this.telegram = telegram; 57 | } 58 | 59 | public class Slack { 60 | private String accessToken; 61 | private String botAccessToken; 62 | private String channel; 63 | 64 | public String getAccessToken() { 65 | return accessToken; 66 | } 67 | 68 | public void setAccessToken(String accessToken) { 69 | this.accessToken = accessToken; 70 | } 71 | 72 | public String getBotAccessToken() { 73 | return botAccessToken; 74 | } 75 | 76 | public void setBotAccessToken(String botAccessToken) { 77 | this.botAccessToken = botAccessToken; 78 | } 79 | 80 | public String getChannel() { 81 | return channel; 82 | } 83 | 84 | public void setChannel(String channel) { 85 | this.channel = channel; 86 | } 87 | } 88 | 89 | public class Logs { 90 | private Integer slowTickerWarning = 3000; 91 | 92 | public Integer getSlowTickerWarning() { 93 | return slowTickerWarning; 94 | } 95 | 96 | public void setSlowTickerWarning(Integer slowTickerWarning) { 97 | this.slowTickerWarning = slowTickerWarning; 98 | } 99 | } 100 | 101 | public class Mail { 102 | private Boolean active; 103 | private String from; 104 | private String to; 105 | 106 | public Boolean getActive() { 107 | return active; 108 | } 109 | 110 | public void setActive(Boolean active) { 111 | this.active = active; 112 | } 113 | 114 | public String getFrom() { 115 | return from; 116 | } 117 | 118 | public void setFrom(String from) { 119 | this.from = from; 120 | } 121 | 122 | public String getTo() { 123 | return to; 124 | } 125 | 126 | public void setTo(String to) { 127 | this.to = to; 128 | } 129 | } 130 | 131 | public class Discord { 132 | private String webhookId; 133 | private String webhookToken; 134 | 135 | public String getWebhookId() { 136 | return webhookId; 137 | } 138 | 139 | public void setWebhookId(String webhookId) { 140 | this.webhookId = webhookId; 141 | } 142 | 143 | public String getWebhookToken() { 144 | return webhookToken; 145 | } 146 | 147 | public void setWebhookToken(String webhookToken) { 148 | this.webhookToken = webhookToken; 149 | } 150 | } 151 | 152 | public class Telegram { 153 | private Boolean active; 154 | private String groupId; 155 | private String token; 156 | 157 | public Boolean getActive() { 158 | return active; 159 | } 160 | 161 | public void setActive(Boolean active) { 162 | this.active = active; 163 | } 164 | 165 | public String getGroupId() { 166 | return groupId; 167 | } 168 | 169 | public void setGroupId(String groupId) { 170 | // Due to telegram appending a 'g' to group chat ids but requiring replacing the 'g' with '-' when sending 171 | // a message to this group, we replace the 'g' from the user config input and/or append the '-' to the start 172 | // of the groupId string 173 | if (!StringUtils.isBlank(groupId) && groupId.startsWith("g")) { 174 | this.groupId = "-" + groupId.substring(1); 175 | return; 176 | } 177 | if (!StringUtils.isBlank(groupId) && !groupId.startsWith("g")) { 178 | this.groupId = "-" + groupId; 179 | return; 180 | } 181 | 182 | if (StringUtils.isBlank(groupId) && active != null && !active) { 183 | // The case when the user did not configure Telegram. E.g. it is not using telegram, we simply accept whatever groupId he added 184 | this.groupId = groupId; 185 | return; 186 | } 187 | 188 | if (active != null && active) { 189 | throw new RuntimeException("Missing groupId value in the Telegram configuration. Please set it in the application.yml file"); 190 | } 191 | 192 | // Telegram is not active so we do not care what value is set for groupId 193 | } 194 | 195 | public String getToken() { 196 | return token; 197 | } 198 | 199 | public void setToken(String token) { 200 | this.token = token; 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/PaperConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import java.math.BigDecimal; 4 | 5 | /** 6 | * Configuration that governs the application's paper trading 7 | */ 8 | public class PaperConfiguration { 9 | private Boolean active = true; 10 | private Boolean autoFill = false; 11 | private BigDecimal initialBalance = new BigDecimal(100); 12 | 13 | public Boolean isActive() { 14 | return active; 15 | } 16 | 17 | public void setActive(Boolean active) { 18 | this.active = active; 19 | } 20 | 21 | public Boolean isAutoFill() { 22 | return autoFill; 23 | } 24 | 25 | public void setAutoFill(Boolean autoFill) { 26 | this.autoFill = autoFill; 27 | } 28 | 29 | public BigDecimal getInitialBalance() { 30 | return initialBalance; 31 | } 32 | 33 | public void setInitialBalance(BigDecimal initialBalance) { 34 | this.initialBalance = initialBalance; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/config/TradingConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.config; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import java.math.BigDecimal; 7 | import java.math.RoundingMode; 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | 11 | import static com.agonyforge.arbitrader.DecimalConstants.USD_SCALE; 12 | 13 | /** 14 | * Configuration that governs the application's trading but isn't specific to one exchange. These settings can 15 | * be set in application.yaml in the "trading" section. 16 | */ 17 | @ConfigurationProperties("trading") 18 | @Configuration 19 | public class TradingConfiguration { 20 | private BigDecimal entrySpreadTarget; 21 | private BigDecimal exitSpreadTarget; 22 | private BigDecimal minimumProfit; 23 | private Boolean spreadNotifications = false; 24 | private BigDecimal fixedExposure; 25 | private List exchanges = new ArrayList<>(); 26 | private List tradeBlacklist = new ArrayList<>(); 27 | private Long tradeTimeout; 28 | private PaperConfiguration paper; 29 | 30 | public BigDecimal getEntrySpreadTarget() { 31 | return entrySpreadTarget; 32 | } 33 | 34 | public void setEntrySpreadTarget(BigDecimal entrySpreadTarget) { 35 | this.entrySpreadTarget = entrySpreadTarget; 36 | } 37 | 38 | public BigDecimal getExitSpreadTarget() { 39 | return exitSpreadTarget; 40 | } 41 | 42 | public void setExitSpreadTarget(BigDecimal exitSpreadTarget) { 43 | this.exitSpreadTarget = exitSpreadTarget; 44 | } 45 | 46 | public BigDecimal getMinimumProfit() { 47 | return minimumProfit; 48 | } 49 | 50 | public void setMinimumProfit(BigDecimal minimumProfit) { 51 | this.minimumProfit = minimumProfit; 52 | } 53 | 54 | public Boolean isSpreadNotifications() { 55 | return spreadNotifications; 56 | } 57 | 58 | public void setSpreadNotifications(Boolean spreadNotifications) { 59 | this.spreadNotifications = spreadNotifications; 60 | } 61 | 62 | public BigDecimal getFixedExposure() { 63 | return fixedExposure; 64 | } 65 | 66 | public void setFixedExposure(BigDecimal fixedExposure) { 67 | this.fixedExposure = fixedExposure.setScale(USD_SCALE, RoundingMode.HALF_EVEN); 68 | } 69 | 70 | public List getExchanges() { 71 | return exchanges; 72 | } 73 | 74 | public void setExchanges(List exchanges) { 75 | this.exchanges = exchanges; 76 | } 77 | 78 | public List getTradeBlacklist() { 79 | return tradeBlacklist; 80 | } 81 | 82 | public void setTradeBlacklist(List tradeBlacklist) { 83 | this.tradeBlacklist = tradeBlacklist; 84 | } 85 | 86 | public Long getTradeTimeout() { 87 | return tradeTimeout; 88 | } 89 | 90 | public void setTradeTimeout(Long tradeTimeout) { 91 | this.tradeTimeout = tradeTimeout; 92 | } 93 | 94 | public PaperConfiguration getPaper() { 95 | return paper; 96 | } 97 | 98 | public void setPaper(PaperConfiguration paper) { 99 | this.paper = paper; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/exception/MarginNotSupportedException.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.exception; 2 | 3 | public class MarginNotSupportedException extends RuntimeException { 4 | public MarginNotSupportedException(String exchangeName) { 5 | super("Margin is not enabled for exchange: " + exchangeName); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/exception/OrderNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.exception; 2 | 3 | import org.knowm.xchange.Exchange; 4 | 5 | /** 6 | * A RuntimeException thrown when an order could not be found on an Exchange. 7 | */ 8 | public class OrderNotFoundException extends RuntimeException { 9 | private final Exchange exchange; 10 | private final String orderId; 11 | 12 | public OrderNotFoundException(Exchange exchange, String orderId) { 13 | super("Order ID " + orderId + " not found on " + exchange.getExchangeSpecification().getExchangeName() + "."); 14 | 15 | this.exchange = exchange; 16 | this.orderId = orderId; 17 | } 18 | 19 | public Exchange getExchange() { 20 | return exchange; 21 | } 22 | 23 | public String getOrderId() { 24 | return orderId; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/logging/DiscordAppender.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.logging; 2 | 3 | import ch.qos.logback.core.AppenderBase; 4 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 5 | import okhttp3.Call; 6 | import okhttp3.Callback; 7 | import okhttp3.MediaType; 8 | import okhttp3.OkHttpClient; 9 | import okhttp3.Request; 10 | import okhttp3.RequestBody; 11 | import okhttp3.Response; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.springframework.context.ApplicationContext; 14 | 15 | import java.io.IOException; 16 | 17 | /** 18 | * Sends slf4j log messages to Discord. 19 | * 20 | * @param the log message. 21 | */ 22 | public class DiscordAppender extends AppenderBase { 23 | public static final MediaType MEDIA_TYPE_JSON = MediaType.get("application/json; charset=utf-8"); 24 | 25 | private final OkHttpClient client = new OkHttpClient(); 26 | 27 | @Override 28 | protected void append(T eventObject) { 29 | final ApplicationContext appContext = SpringContextSingleton.getInstance().getApplicationContext(); 30 | 31 | if (appContext == null) { 32 | return; 33 | } 34 | 35 | final NotificationConfiguration notificationConfig = (NotificationConfiguration) appContext.getBean("notificationConfiguration"); 36 | final String url = "https://discord.com/api/webhooks/" + notificationConfig.getDiscord().getWebhookId() + "/" + 37 | notificationConfig.getDiscord().getWebhookToken(); 38 | 39 | final String bodyContent = "{\"content\": \"" + eventObject.toString() + "\" }"; 40 | final RequestBody body = RequestBody.create(bodyContent, MEDIA_TYPE_JSON); 41 | final Request request = new Request.Builder() 42 | .url(url) 43 | .post(body) 44 | .build(); 45 | 46 | // Send the log message asynchronously to Discord via webhook 47 | client.newCall(request).enqueue(new Callback() { 48 | @Override 49 | public void onFailure(@NotNull Call call, @NotNull IOException e) { 50 | // Cancel the connection in case it is still active. There is no problem calling cancel() on an 51 | // already canceled connection 52 | call.cancel(); 53 | } 54 | @Override 55 | public void onResponse(@NotNull Call call, @NotNull Response response) { 56 | // We need to close the response in order to avoid leaking it 57 | response.close(); 58 | } 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/logging/SlackAppender.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.logging; 2 | 3 | import ch.qos.logback.core.AppenderBase; 4 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 5 | import com.github.seratch.jslack.Slack; 6 | import com.github.seratch.jslack.api.methods.SlackApiException; 7 | import com.github.seratch.jslack.api.methods.request.chat.ChatPostMessageRequest; 8 | import org.springframework.context.ApplicationContext; 9 | 10 | import java.io.IOException; 11 | import java.util.Collections; 12 | 13 | /** 14 | * Sends slf4j log messages to a Slack channel. 15 | * 16 | * @param the log message to send. 17 | */ 18 | public class SlackAppender extends AppenderBase { 19 | @Override 20 | protected void append(T eventObject) { 21 | ApplicationContext applicationContext = SpringContextSingleton.getInstance().getApplicationContext(); 22 | 23 | if (applicationContext == null) { 24 | return; 25 | } 26 | 27 | NotificationConfiguration notificationConfiguration = (NotificationConfiguration) applicationContext.getBean("notificationConfiguration"); 28 | 29 | try { 30 | Slack.getInstance().methods().chatPostMessage(ChatPostMessageRequest.builder() 31 | .token(notificationConfiguration.getSlack().getAccessToken()) 32 | .asUser(false) 33 | .channel(notificationConfiguration.getSlack().getChannel()) 34 | .text(eventObject.toString()) 35 | .attachments(Collections.emptyList()) 36 | .build()); 37 | } catch (IOException | SlackApiException e) { 38 | // can't log here or we'll cause an endless loop... 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/logging/SpringContextSingleton.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.logging; 2 | 3 | import org.springframework.beans.BeansException; 4 | import org.springframework.context.ApplicationContext; 5 | import org.springframework.context.ApplicationContextAware; 6 | import org.springframework.stereotype.Component; 7 | 8 | import javax.annotation.PostConstruct; 9 | 10 | /** 11 | * Holds the Spring ApplicationContext so that non-Spring beans can access it. 12 | */ 13 | @Component 14 | public class SpringContextSingleton implements ApplicationContextAware { 15 | private static SpringContextSingleton instance = null; 16 | 17 | public static SpringContextSingleton getInstance() { 18 | return instance; 19 | } 20 | 21 | private ApplicationContext applicationContext; 22 | 23 | @PostConstruct 24 | public void setup() { 25 | instance = this; 26 | } 27 | 28 | @Override 29 | public void setApplicationContext(@SuppressWarnings("NullableProblems") ApplicationContext applicationContext) throws BeansException { 30 | this.applicationContext = applicationContext; 31 | } 32 | 33 | public ApplicationContext getApplicationContext() { 34 | return applicationContext; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ConditionService.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import org.apache.commons.io.FileUtils; 4 | import org.knowm.xchange.Exchange; 5 | import org.knowm.xchange.currency.CurrencyPair; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.io.File; 11 | import java.io.IOException; 12 | import java.nio.charset.Charset; 13 | import java.time.ZonedDateTime; 14 | import java.time.format.DateTimeFormatter; 15 | import java.util.List; 16 | 17 | /** 18 | * A service to detect several different conditions that can control Arbitrader's behavior without exposing 19 | * the nature of the implementation of those conditions. Decoupling like this provides flexibility in the 20 | * future in case we want to change how these signals are generated or add other ways of sending the signals. 21 | */ 22 | @Component 23 | public class ConditionService { 24 | static final String FORCE_OPEN = "force-open"; 25 | static final String FORCE_CLOSE = "force-close"; 26 | static final String EXIT_WHEN_IDLE = "exit-when-idle"; 27 | static final String STATUS = "status"; 28 | static final String BLACKOUT = "blackout"; 29 | 30 | private static final Logger LOGGER = LoggerFactory.getLogger(ConditionService.class); 31 | 32 | private final File forceOpenFile = new File(FORCE_OPEN); 33 | private final File forceCloseFile = new File(FORCE_CLOSE); 34 | private final File exitWhenIdleFile = new File(EXIT_WHEN_IDLE); 35 | private final File statusFile = new File(STATUS); 36 | private final File blackoutFile = new File(BLACKOUT); 37 | 38 | /** 39 | * Is the "force a trade to open" condition enabled? 40 | * 41 | * @param currencyPair A CurrencyPair 42 | * @param longExchangeName The name of the long Exchange. 43 | * @param shortExchangeName The name of the short Exchange. 44 | * @return true if we should force a trade to open. 45 | */ 46 | public boolean isForceOpenCondition(CurrencyPair currencyPair, String longExchangeName, String shortExchangeName) { 47 | return forceOpenFile.exists() && evaluateForceOpenCondition(currencyPair, longExchangeName, shortExchangeName); 48 | } 49 | 50 | private boolean evaluateForceOpenCondition(CurrencyPair currencyPair, String longExchangeName, String shortExchangeName) { 51 | String exchanges; 52 | 53 | try { 54 | exchanges = FileUtils.readFileToString(forceOpenFile, Charset.defaultCharset()).trim(); 55 | } catch (IOException e) { 56 | LOGGER.warn("IOException reading file '{}': {}", FORCE_OPEN, e.getMessage()); 57 | return false; 58 | } 59 | 60 | // The force-open file should contain the names of the exchanges you want to force a trade on. 61 | // It's meant to be a tool to aid testing entry and exit on specific pairs of exchanges. 62 | // 63 | // In this format: currencyPair longExchange/shortExchange 64 | // For example: BTC/USD BitFlyer/Kraken 65 | String current = String.format("%s %s/%s", 66 | currencyPair, 67 | longExchangeName, 68 | shortExchangeName); 69 | 70 | return current.equals(exchanges); 71 | } 72 | 73 | /** 74 | * Removes the "force a trade to open" condition. 75 | */ 76 | public void clearForceOpenCondition() { 77 | FileUtils.deleteQuietly(forceOpenFile); 78 | } 79 | 80 | /** 81 | * Is the "force trades to close" condition enabled? 82 | * 83 | * @return true if we should force our open trades to close. 84 | */ 85 | public boolean isForceCloseCondition() { 86 | return forceCloseFile.exists(); 87 | } 88 | 89 | /** 90 | * Removes the "force trades to close" condition. 91 | */ 92 | public void clearForceCloseCondition() { 93 | FileUtils.deleteQuietly(forceCloseFile); 94 | } 95 | 96 | /** 97 | * Is the "exit when idle" condition enabled? 98 | * 99 | * @return true if we should exit the next time the bot is idle. 100 | */ 101 | public boolean isExitWhenIdleCondition() { 102 | return exitWhenIdleFile.exists(); 103 | } 104 | 105 | /** 106 | * Removes the "exit when idle" condition. 107 | */ 108 | public void clearExitWhenIdleCondition() { 109 | FileUtils.deleteQuietly(exitWhenIdleFile); 110 | } 111 | 112 | /** 113 | * Is the "status report" condition enabled? 114 | * 115 | * @return true if we should generate a status report. 116 | */ 117 | public boolean isStatusCondition() { 118 | return statusFile.exists(); 119 | } 120 | 121 | /** 122 | * Removes the "status report" condition. 123 | */ 124 | public void clearStatusCondition() { 125 | FileUtils.deleteQuietly(statusFile); 126 | } 127 | 128 | /** 129 | * Are we inside a user-configured blackout window? 130 | * 131 | * @param exchange The Exchange to check for blackout windows. 132 | * @return true if we are within a blackout window for the given Exchange. 133 | */ 134 | public boolean isBlackoutCondition(Exchange exchange) { 135 | if (!blackoutFile.exists() || !blackoutFile.canRead()) { 136 | return false; 137 | } 138 | 139 | try { 140 | List lines = FileUtils.readLines(blackoutFile, Charset.defaultCharset()); 141 | 142 | return lines 143 | .stream() 144 | .filter(line -> line.startsWith(exchange.getExchangeSpecification().getExchangeName())) 145 | .anyMatch(this::checkBlackoutWindow); 146 | 147 | } catch (IOException e) { 148 | LOGGER.error("Blackout file exists but cannot be read!", e); 149 | } 150 | 151 | return false; 152 | } 153 | 154 | // checks a blackout window line to see if the current time is within it 155 | private boolean checkBlackoutWindow(String line) { 156 | String[] dateStrings = line.split("[,]"); 157 | 158 | if (dateStrings.length != 3) { 159 | return false; 160 | } 161 | 162 | ZonedDateTime start = ZonedDateTime.parse(dateStrings[1], DateTimeFormatter.ISO_OFFSET_DATE_TIME); 163 | ZonedDateTime end = ZonedDateTime.parse(dateStrings[2], DateTimeFormatter.ISO_OFFSET_DATE_TIME); 164 | ZonedDateTime now = ZonedDateTime.now(); 165 | 166 | return now.isAfter(start) && now.isBefore(end); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ErrorCollectorService.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import org.knowm.xchange.Exchange; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.util.ArrayList; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.Map; 12 | import java.util.stream.Collectors; 13 | 14 | /** 15 | * Collect non-critical errors and report them together as a batch or summary. 16 | * This reduces unimportant things in the logs and saves from rate limiting when sending logs to other services. 17 | */ 18 | @Component 19 | public class ErrorCollectorService { 20 | static final String HEADER = "Noncritical error summary: [Exception name]: [Error message] x [Count]"; 21 | 22 | private static final Logger LOGGER = LoggerFactory.getLogger(ErrorCollectorService.class); 23 | 24 | private final Map errors = new HashMap<>(); 25 | 26 | /** 27 | * Collect an error and store it. 28 | * 29 | * @param exchange The Exchange this error is related to. 30 | * @param t The error object. 31 | */ 32 | public void collect(Exchange exchange, Throwable t) { 33 | // store the error in the map and increment the count if there's already a similar error 34 | errors.compute(computeKey(exchange, t), (key, value) -> (value == null ? 0 : value) + 1); 35 | 36 | // when DEBUG is enabled, show the exception to help with debugging problems 37 | LOGGER.debug("Surfacing noncritical stack trace for debugging: ", t); 38 | } 39 | 40 | /** 41 | * Tells whether the error collector is empty. 42 | * 43 | * @return true if the error collector is empty. 44 | */ 45 | public boolean isEmpty() { 46 | return errors.size() == 0; 47 | } 48 | 49 | /** 50 | * Clear any errors stored in the error collector. 51 | */ 52 | public void clear() { 53 | errors.clear(); 54 | } 55 | 56 | /** 57 | * Generate a report of any errors stored in the error collector. 58 | * 59 | * @return a report of stored errors, formatted as a list of strings 60 | */ 61 | public List report() { 62 | List report = new ArrayList<>(); 63 | 64 | report.add(HEADER); 65 | report.addAll(errors.entrySet() 66 | .stream() 67 | .map(entry -> entry.getKey() + " x " + entry.getValue()) 68 | .collect(Collectors.toList())); 69 | 70 | return report; 71 | } 72 | 73 | // compute a string based on an Exchange and a Throwable, suitable for use as a key in a Map 74 | private String computeKey(Exchange exchange, Throwable t) { 75 | return exchange.getExchangeSpecification().getExchangeName() + ": " + t.getClass().getSimpleName() + " " + t.getMessage(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/LiquidityException.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | /** 4 | * Thrown when an exchange has too little liquidity for us to place the order we want. 5 | */ 6 | public class LiquidityException extends RuntimeException { 7 | public LiquidityException(String message) { 8 | super(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/NotificationService.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import com.agonyforge.arbitrader.service.model.Spread; 4 | import com.agonyforge.arbitrader.service.model.EntryTradeVolume; 5 | import com.agonyforge.arbitrader.service.model.ExitTradeVolume; 6 | 7 | import java.math.BigDecimal; 8 | 9 | /** 10 | * An email notification service. 11 | */ 12 | public interface NotificationService { 13 | void sendNotification(String subject, String message); 14 | void sendEntryTradeNotification(Spread spread, BigDecimal exitTarget, EntryTradeVolume tradeVolume, 15 | BigDecimal longLimitPrice, BigDecimal shortLimitPrice, boolean isForceEntryPosition); 16 | void sendExitTradeNotification(Spread spread, ExitTradeVolume tradeVolume, BigDecimal longLimitPrice, 17 | BigDecimal shortLimitPrice, BigDecimal entryBalance, BigDecimal updatedBalance, BigDecimal exitTarget, 18 | boolean isForceCloseCondition, boolean isActivePositionExpired); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/NotificationServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import com.agonyforge.arbitrader.service.model.Spread; 4 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 5 | import com.agonyforge.arbitrader.service.model.EntryTradeVolume; 6 | import com.agonyforge.arbitrader.service.model.ExitTradeVolume; 7 | import com.agonyforge.arbitrader.service.telegram.TelegramClient; 8 | import org.knowm.xchange.currency.Currency; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.mail.SimpleMailMessage; 12 | import org.springframework.mail.javamail.JavaMailSender; 13 | import org.springframework.scheduling.annotation.Async; 14 | import org.springframework.stereotype.Service; 15 | 16 | import javax.inject.Inject; 17 | import java.math.BigDecimal; 18 | 19 | /** 20 | * Send notifications. 21 | * This class should be a central point where all outbound notifications and created and processed. 22 | */ 23 | @Service 24 | @Async 25 | public class NotificationServiceImpl implements NotificationService { 26 | private static final Logger LOGGER = LoggerFactory.getLogger(NotificationServiceImpl.class); 27 | public static final String EMAIL_SUBJECT_NEW_ENTRY = "Arbitrader - New Entry Trade"; 28 | public static final String EMAIL_SUBJECT_NEW_EXIT = "Arbitrader - New Exit Trade"; 29 | 30 | private final JavaMailSender javaMailSender; 31 | private final NotificationConfiguration notificationConfiguration; 32 | private final TelegramClient telegramClient; 33 | 34 | @Inject 35 | public NotificationServiceImpl(JavaMailSender javaMailSender, NotificationConfiguration notificationConfiguration, TelegramClient telegramClient) { 36 | this.javaMailSender = javaMailSender; 37 | this.notificationConfiguration = notificationConfiguration; 38 | this.telegramClient = telegramClient; 39 | } 40 | 41 | /** 42 | * Formats and sends a notification when we trade an entry position. 43 | * 44 | * @param spread The Spread. 45 | * @param exitTarget The exit target. 46 | * @param tradeVolume The traded volumes. 47 | * @param longLimitPrice The long exchange limit price. 48 | * @param shortLimitPrice The short exchange limit price. 49 | * @param isForceEntryPosition Flag to indicate if this entry trade is forced or not. 50 | */ 51 | @Override 52 | public void sendEntryTradeNotification(Spread spread, BigDecimal exitTarget, EntryTradeVolume tradeVolume, BigDecimal longLimitPrice, 53 | BigDecimal shortLimitPrice, boolean isForceEntryPosition) { 54 | 55 | final String longEntryString = String.format("Long entry: %s %s %s @ %s (slipped from %s) = %s%s (slipped from %s%s)\n", 56 | spread.getLongExchange().getExchangeSpecification().getExchangeName(), 57 | spread.getCurrencyPair(), 58 | tradeVolume.getLongVolume().toPlainString(), 59 | longLimitPrice.toPlainString(), 60 | spread.getLongTicker().getAsk().toPlainString(), 61 | Currency.USD.getSymbol(), 62 | tradeVolume.getLongVolume().multiply(longLimitPrice).toPlainString(), 63 | Currency.USD.getSymbol(), 64 | tradeVolume.getLongVolume().multiply(spread.getLongTicker().getAsk()).toPlainString()); 65 | 66 | final String shortEntryString = String.format("Short entry: %s %s %s @ %s (slipped %s) = %s%s (slipped from %s%s)\n", 67 | spread.getShortExchange().getExchangeSpecification().getExchangeName(), 68 | spread.getCurrencyPair(), 69 | tradeVolume.getShortVolume().toPlainString(), 70 | shortLimitPrice.toPlainString(), 71 | spread.getShortTicker().getBid().toPlainString(), 72 | Currency.USD.getSymbol(), 73 | tradeVolume.getShortVolume().multiply(shortLimitPrice).toPlainString(), 74 | Currency.USD.getSymbol(), 75 | tradeVolume.getShortVolume().multiply(spread.getShortTicker().getBid()).toPlainString()); 76 | 77 | final String message = isForceEntryPosition ? "***** FORCED ENTRY *****\n" : "***** ENTRY *****\n" + 78 | String.format("Entry spread: %s\n", spread.getIn().toPlainString()) + 79 | String.format("Exit spread target: %s\n", exitTarget.toPlainString()) + 80 | String.format("Market neutrality rating: %s\n", tradeVolume.getMarketNeutralityRating()) + 81 | String.format("Minimum profit estimation: %s%s\n", Currency.USD.getSymbol(), tradeVolume.getMinimumProfit(longLimitPrice, shortLimitPrice))+ 82 | longEntryString + 83 | shortEntryString; 84 | 85 | sendNotification(EMAIL_SUBJECT_NEW_ENTRY, message); 86 | } 87 | 88 | /** 89 | * Format and send a notification when a trade exits. 90 | * 91 | * @param spread The Spread. 92 | * @param tradeVolume The traded volumes 93 | * @param longLimitPrice The long exchange limit price. 94 | * @param shortLimitPrice The short exchange limit price. 95 | * @param entryBalance The combined account balances when the trades were first entered. 96 | * @param updatedBalance The new account balances after exiting the trades. 97 | * @param exitTarget The spread exit target. 98 | * @param isForceExitPosition Flag to indicate if this exit trade is forced or not. 99 | * @param isActivePositionExpired Flag to indicate if this exit trade is due to a timeout (position time expired). 100 | */ 101 | @Override 102 | public void sendExitTradeNotification(Spread spread, ExitTradeVolume tradeVolume, BigDecimal longLimitPrice, 103 | BigDecimal shortLimitPrice, BigDecimal entryBalance, BigDecimal updatedBalance, BigDecimal exitTarget, 104 | boolean isForceExitPosition, boolean isActivePositionExpired) { 105 | 106 | final String exitSpreadString = String.format("Exit spread: %s\nExit spread target %s\n", spread.getOut(), exitTarget); 107 | 108 | final String longCloseString = String.format("Long close: %s %s %s @ %s (slipped from %s) = %s%s (slipped from %s%s)\n", 109 | spread.getLongExchange().getExchangeSpecification().getExchangeName(), 110 | spread.getCurrencyPair(), 111 | tradeVolume.getLongVolume().toPlainString(), 112 | longLimitPrice.toPlainString(), 113 | spread.getLongTicker().getBid().toPlainString(), 114 | Currency.USD.getSymbol(), 115 | tradeVolume.getLongVolume().multiply(longLimitPrice).toPlainString(), 116 | Currency.USD.getSymbol(), 117 | tradeVolume.getLongVolume().multiply(spread.getLongTicker().getBid()).toPlainString()); 118 | 119 | final String shortCloseString = String.format("Short close: %s %s %s @ %s (slipped from %s) = %s%s (slipped from %s%s)\n", 120 | spread.getShortExchange().getExchangeSpecification().getExchangeName(), 121 | spread.getCurrencyPair(), 122 | tradeVolume.getShortVolume().toPlainString(), 123 | shortLimitPrice.toPlainString(), 124 | spread.getShortTicker().getAsk().toPlainString(), 125 | Currency.USD.getSymbol(), 126 | tradeVolume.getShortVolume().multiply(shortLimitPrice).toPlainString(), 127 | Currency.USD.getSymbol(), 128 | tradeVolume.getShortVolume().multiply(spread.getShortTicker().getAsk()).toPlainString()); 129 | 130 | final BigDecimal profit = updatedBalance.subtract(entryBalance); 131 | 132 | final String startOfMessage; 133 | if (isActivePositionExpired) { 134 | startOfMessage = "***** TIMEOUT EXIT *****\n"; 135 | } 136 | else if (isForceExitPosition){ 137 | startOfMessage = "***** FORCED EXIT *****\n"; 138 | } 139 | else { 140 | startOfMessage = "***** EXIT *****\n"; 141 | } 142 | 143 | final String message = startOfMessage + 144 | exitSpreadString + 145 | longCloseString + 146 | shortCloseString + 147 | String.format("Combined account balances on entry: $%s\n", entryBalance.toPlainString()) + 148 | String.format("Profit calculation: $%s - $%s = $%s\n", updatedBalance.toPlainString(), entryBalance.toPlainString(), profit.toPlainString()); 149 | 150 | sendNotification(EMAIL_SUBJECT_NEW_EXIT, message); 151 | } 152 | 153 | /** 154 | * Send the notification via email and/or Telegram. 155 | * @param subject the notification title (in case of email) 156 | * @param message the notification body/message 157 | */ 158 | @Override 159 | public void sendNotification(String subject, String message) { 160 | sendInstantMessage(message); 161 | sendEmail(subject, message); 162 | } 163 | 164 | /** 165 | * Send an email notification, if email is configured. 166 | * 167 | * @param subject The subject line of the email. 168 | * @param body The body of the email. 169 | */ 170 | private void sendEmail(String subject, String body) { 171 | if (notificationConfiguration.getMail() == null || notificationConfiguration.getMail().getActive() == null || 172 | !notificationConfiguration.getMail().getActive()) { 173 | 174 | LOGGER.info("Email notification is disabled"); 175 | return; 176 | } 177 | 178 | SimpleMailMessage mail = new SimpleMailMessage(); 179 | mail.setTo(notificationConfiguration.getMail().getTo()); 180 | mail.setFrom(notificationConfiguration.getMail().getFrom()); 181 | mail.setSubject(subject); 182 | mail.setText(body); 183 | 184 | try { 185 | javaMailSender.send(mail); 186 | } 187 | catch (Exception e) { 188 | LOGGER.error("Could not send email notification to {}. Reason: {}", notificationConfiguration.getMail().getTo(), e.getMessage()); 189 | } 190 | } 191 | 192 | /** 193 | * Send an instant message notification. Currently only supports instant messages via Telegram. 194 | * Check the wiki page for more details on how to receive instant messages via Telegram. 195 | * @param message the message to send 196 | * @see Wiki Page 197 | */ 198 | private void sendInstantMessage(String message) { 199 | if (notificationConfiguration.getTelegram() == null || notificationConfiguration.getTelegram().getActive() == null || 200 | !notificationConfiguration.getTelegram().getActive()) { 201 | 202 | LOGGER.info("Instant messaging notification is disabled"); 203 | return; 204 | } 205 | 206 | final String groupId = notificationConfiguration.getTelegram().getGroupId(); 207 | 208 | if (groupId.isEmpty()) { 209 | LOGGER.error("Missing groupId in the telegram configuation. Set it in application.yml file"); 210 | return; 211 | } 212 | 213 | try { 214 | telegramClient.sendMessage(message, groupId); 215 | } 216 | catch (Exception e) { 217 | LOGGER.error("Could not instant message notification to groupId {}. Reason: {}", groupId, e.getMessage()); 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/cache/ExchangeBalanceCache.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.cache; 2 | 3 | import org.knowm.xchange.Exchange; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.math.BigDecimal; 8 | import java.util.Arrays; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | import java.util.Optional; 12 | import java.util.stream.Collectors; 13 | 14 | /** 15 | * Cache account balances to avoid rate limiting. Balances do change pretty frequently so we 16 | * don't cache them for long, but we can avoid some repetitive calls without risking incorrect 17 | * information. 18 | */ 19 | public class ExchangeBalanceCache { 20 | private static final Logger LOGGER = LoggerFactory.getLogger(ExchangeBalanceCache.class); 21 | 22 | public static final long CACHE_TIMEOUT = 1000 * 60; // 1 minute 23 | 24 | private final Map cache = new HashMap<>(); 25 | 26 | /** 27 | * Retrieve a balance from the cache. 28 | * 29 | * @param exchange The exchange to retrieve a balance for. 30 | * @return The account balance for the requested exchange. 31 | */ 32 | public Optional getCachedBalance(Exchange exchange) { 33 | AccountBalance balance = cache.get(exchange); 34 | 35 | if (balance == null) { 36 | LOGGER.debug("Cache did not contain a value for exchange {}", exchange.getExchangeSpecification().getExchangeName()); 37 | return Optional.empty(); 38 | } 39 | 40 | if (System.currentTimeMillis() - balance.getTimestamp() > CACHE_TIMEOUT) { 41 | LOGGER.debug("Cache had an expired value for exchange {}", exchange.getExchangeSpecification().getExchangeName()); 42 | return Optional.empty(); 43 | } 44 | 45 | LOGGER.debug("Cache returned a cached value for exchange {}", exchange.getExchangeSpecification().getExchangeName()); 46 | return Optional.of(balance.getAmount()); 47 | } 48 | 49 | /** 50 | * Put a balance into the cache. 51 | * 52 | * @param exchange The exchange the balance is associated with. 53 | * @param amount The amount of the account balance. 54 | */ 55 | public void setCachedBalance(Exchange exchange, BigDecimal amount) { 56 | setCachedBalance(exchange, amount, System.currentTimeMillis()); 57 | } 58 | 59 | // intended for testing so that you can set your own timestamp 60 | // if you want to test that cached items "expire" correctly without 61 | // actually waiting for them to expire 62 | void setCachedBalance(Exchange exchange, BigDecimal amount, long timestamp) { 63 | AccountBalance balance = new AccountBalance(amount, timestamp); 64 | 65 | LOGGER.debug("Caching new value: {} -> {}", exchange.getExchangeSpecification().getExchangeName(), amount); 66 | cache.put(exchange, balance); 67 | } 68 | 69 | /** 70 | * Remove a cached value, if any exists, for the given exchanges. 71 | * This method is useful if we have taken some action such as executing a trade and 72 | * we know for sure that the account balance has changed. 73 | * 74 | * @param exchanges The exchanges to invalidate. 75 | */ 76 | public void invalidate(Exchange ... exchanges) { 77 | if (LOGGER.isDebugEnabled()) { // avoid the stream/map/collect if DEBUG is turned off 78 | String exchangeNames = Arrays 79 | .stream(exchanges) 80 | .map(exchange -> exchange.getExchangeSpecification().getExchangeName()) 81 | .collect(Collectors.joining(", ")); 82 | 83 | LOGGER.debug("Cache invalidating exchanges: {}", exchangeNames); 84 | } 85 | 86 | Arrays.stream(exchanges).forEach(cache::remove); 87 | } 88 | 89 | private static class AccountBalance { 90 | private final BigDecimal amount; 91 | private final long timestamp; 92 | 93 | public AccountBalance(BigDecimal amount, long timestamp) { 94 | this.amount = amount; 95 | this.timestamp = timestamp; 96 | } 97 | 98 | public BigDecimal getAmount() { 99 | return amount; 100 | } 101 | 102 | public long getTimestamp() { 103 | return timestamp; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/cache/ExchangeFeeCache.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.cache; 2 | 3 | import com.agonyforge.arbitrader.service.model.ExchangeFee; 4 | import org.knowm.xchange.Exchange; 5 | import org.knowm.xchange.currency.CurrencyPair; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | import java.util.Optional; 11 | 12 | /** 13 | * Cache exchange fee amounts. They don't change that often and we request them frequently, 14 | * so this saves us from a lot of API rate limiting. 15 | */ 16 | @Component 17 | public class ExchangeFeeCache { 18 | private final Map cache = new HashMap<>(); 19 | 20 | /** 21 | * Return a fee from the cache. 22 | * 23 | * @param exchange The Exchange to fetch a fee from. 24 | * @param currencyPair The CurrencyPair to fetch a fee from. 25 | * @return The fee as a decimal such as 0.0016, or 0.16% 26 | */ 27 | public Optional getCachedFee(Exchange exchange, CurrencyPair currencyPair) { 28 | return Optional.ofNullable(cache.get(computeCacheKey(exchange, currencyPair))); 29 | } 30 | 31 | /** 32 | * Include a fee in the cache. 33 | * 34 | * @param exchange The Exchange this fee comes from. 35 | * @param currencyPair The CurrencyPair this fee is for. 36 | * @param fee The fee as a decimal, such as 0.0016 for 0.16% 37 | */ 38 | public void setCachedFee(Exchange exchange, CurrencyPair currencyPair, ExchangeFee fee) { 39 | cache.put(computeCacheKey(exchange, currencyPair), fee); 40 | } 41 | 42 | // generate a string that represents an exchange and currency pair, suitable for use as a key in a Map 43 | private String computeCacheKey(Exchange exchange, CurrencyPair currencyPair) { 44 | return String.format("%s:%s", 45 | exchange.getExchangeSpecification().getExchangeName(), 46 | currencyPair.toString()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/cache/OrderVolumeCache.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.cache; 2 | 3 | import org.knowm.xchange.Exchange; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.math.BigDecimal; 8 | import java.util.ArrayList; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.Map; 12 | import java.util.Optional; 13 | 14 | /** 15 | * Cache order volumes to avoid rate limiting. Order volumes don't change 16 | * once they're placed so they're safe to cache. 17 | */ 18 | public class OrderVolumeCache { 19 | public static final int CACHE_SIZE = 4; 20 | 21 | private static final Logger LOGGER = LoggerFactory.getLogger(OrderVolumeCache.class); 22 | 23 | private final Map cache = new HashMap<>(); 24 | private final List keys = new ArrayList<>(); 25 | 26 | /** 27 | * Return a cached volume by exchange and order ID. 28 | * 29 | * @param exchange The exchange the order is on. 30 | * @param orderId The order ID of the order. 31 | * @return The volume of the order, if it is in the cache. 32 | */ 33 | public Optional getCachedVolume(Exchange exchange, String orderId) { 34 | BigDecimal value = cache.get(computeCacheKey(exchange, orderId)); 35 | 36 | if (value == null) { 37 | LOGGER.debug("Cache returned null for order {}:{}", 38 | exchange.getExchangeSpecification().getExchangeName(), 39 | orderId); 40 | 41 | return Optional.empty(); 42 | } 43 | 44 | LOGGER.debug("Cache returned a cached volume for order {}:{}", 45 | exchange.getExchangeSpecification().getExchangeName(), 46 | orderId); 47 | 48 | return Optional.of(value); 49 | } 50 | 51 | /** 52 | * Put an order volume into the cache. If the cache is larger than CACHE_SIZE 53 | * after adding the new value, orders will be removed in the order they were added 54 | * (first in, first out) until the size of the cache equals CACHE_SIZE. This is 55 | * a feature to avoid unbounded memory growth if Arbitrader is left running 56 | * for a long period of time. There is no reason at the time of writing this that 57 | * we would ever want to look up an order volume for a closed order. 58 | * 59 | * @param exchange The exchange the order is on. 60 | * @param orderId The order ID of the order. 61 | * @param volume The volume of the order. 62 | */ 63 | public void setCachedVolume(Exchange exchange, String orderId, BigDecimal volume) { 64 | LOGGER.debug("Caching new value: {}:{} -> {}", 65 | exchange.getExchangeSpecification().getExchangeName(), 66 | orderId, 67 | volume); 68 | 69 | // add the new key to the cache 70 | cache.put(computeCacheKey(exchange, orderId), volume); 71 | keys.add(computeCacheKey(exchange, orderId)); 72 | 73 | // if the cache is too large, start removing items (FIFO) until it has CACHE_SIZE items 74 | if (keys.size() > CACHE_SIZE) { 75 | while (keys.size() > CACHE_SIZE) { 76 | cache.remove(keys.get(0)); 77 | keys.remove(0); 78 | } 79 | } 80 | } 81 | 82 | // compute a String key suitable for use as the key in a Map 83 | private String computeCacheKey(Exchange exchange, String orderId) { 84 | return String.format("%s:%s", exchange.getExchangeSpecification().getExchangeName(), orderId); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/event/TickerEventListener.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.event; 2 | 3 | import com.agonyforge.arbitrader.service.SpreadService; 4 | import com.agonyforge.arbitrader.service.TickerService; 5 | import com.agonyforge.arbitrader.service.model.Spread; 6 | import com.agonyforge.arbitrader.service.model.TickerEvent; 7 | import com.agonyforge.arbitrader.service.TradingService; 8 | import com.agonyforge.arbitrader.service.model.TradeCombination; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.context.event.EventListener; 12 | import org.springframework.scheduling.annotation.Async; 13 | import org.springframework.stereotype.Component; 14 | 15 | import java.util.List; 16 | 17 | /** 18 | * Listens for TickerEvents and starts analysis for trading when an event is received. 19 | */ 20 | @Component 21 | public class TickerEventListener { 22 | private static final Logger LOGGER = LoggerFactory.getLogger(TickerEventListener.class); 23 | 24 | private final TradingService tradingService; 25 | private final TickerService tickerService; 26 | private final SpreadService spreadService; 27 | 28 | public TickerEventListener( 29 | TradingService tradingService, 30 | TickerService tickerService, 31 | SpreadService spreadService) { 32 | 33 | this.tradingService = tradingService; 34 | this.tickerService = tickerService; 35 | this.spreadService = spreadService; 36 | } 37 | 38 | /** 39 | * Initiate trade analysis when a TickerEvent is received, but only for trade combinations that involve 40 | * the exchange and currency pair that was updated. This code runs every time a ticker is received so it's 41 | * important to make it as fast and as lightweight as possible. 42 | * 43 | * @param tickerEvent The TickerEvent we received. 44 | */ 45 | @EventListener 46 | @Async 47 | public void onTradeEvent(TickerEvent tickerEvent) { 48 | LOGGER.trace("Received ticker event: {} {} {}/{}", 49 | tickerEvent.getExchange().getExchangeSpecification().getExchangeName(), 50 | tickerEvent.getTicker().getInstrument(), 51 | tickerEvent.getTicker().getBid(), 52 | tickerEvent.getTicker().getAsk()); 53 | 54 | List tradeCombinations = tickerService.getExchangeTradeCombinations(); 55 | 56 | tradeCombinations 57 | .stream() 58 | // only consider combinations where one of the exchanges is from the event 59 | .filter(tradeCombination -> ( 60 | tradeCombination.getLongExchange().equals(tickerEvent.getExchange()) 61 | || tradeCombination.getShortExchange().equals(tickerEvent.getExchange()) 62 | )) 63 | // only consider combinations where the currency pair matches the event 64 | .filter(tradeCombination -> tradeCombination.getCurrencyPair().equals(tickerEvent.getTicker().getInstrument())) 65 | .forEach(tradeCombination -> { 66 | Spread spread = spreadService.computeSpread(tradeCombination); 67 | 68 | if (spread != null) { // spread will be null if any tickers were missing for this combination 69 | final long start = System.currentTimeMillis(); 70 | tradingService.trade(spread); 71 | 72 | LOGGER.debug("Analyzed {} ({} ms)", spread, System.currentTimeMillis() - start); 73 | } 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/event/TickerEventPublisher.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.event; 2 | 3 | import com.agonyforge.arbitrader.service.model.TickerEvent; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.context.ApplicationEventPublisher; 7 | import org.springframework.stereotype.Component; 8 | 9 | /** 10 | * Publishes ticker events. 11 | */ 12 | @Component 13 | public class TickerEventPublisher { 14 | private static final Logger LOGGER = LoggerFactory.getLogger(TickerEventPublisher.class); 15 | 16 | private final ApplicationEventPublisher applicationEventPublisher; 17 | 18 | public TickerEventPublisher(ApplicationEventPublisher applicationEventPublisher) { 19 | this.applicationEventPublisher = applicationEventPublisher; 20 | } 21 | 22 | /** 23 | * Publish a TickerEvent. 24 | * 25 | * @param tickerEvent the TickerEvent to publish. 26 | */ 27 | public void publishTicker(TickerEvent tickerEvent) { 28 | LOGGER.trace("Publishing ticker event: {} {} {}/{}", 29 | tickerEvent.getExchange().getExchangeSpecification().getExchangeName(), 30 | tickerEvent.getTicker().getInstrument(), 31 | tickerEvent.getTicker().getBid(), 32 | tickerEvent.getTicker().getAsk()); 33 | 34 | applicationEventPublisher.publishEvent(tickerEvent); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/ActivePosition.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import org.knowm.xchange.Exchange; 5 | import org.knowm.xchange.currency.CurrencyPair; 6 | 7 | import java.math.BigDecimal; 8 | import java.time.OffsetDateTime; 9 | import java.util.Objects; 10 | 11 | /** 12 | * All the information we need to store an active pair of trades to disk, and load it back up later. 13 | * This is used in the state file to enable us to shut Arbitrader down and start it back up again without 14 | * losing any information. 15 | */ 16 | @JsonIgnoreProperties(ignoreUnknown=true) 17 | public class ActivePosition { 18 | private final Trade longTrade = new Trade(); 19 | private final Trade shortTrade = new Trade(); 20 | private CurrencyPair currencyPair; 21 | private BigDecimal exitTarget; 22 | private BigDecimal entryBalance; // USD balance of both exchanges summed when the trades were first opened 23 | private OffsetDateTime entryTime; 24 | 25 | public Trade getLongTrade() { 26 | return longTrade; 27 | } 28 | 29 | public Trade getShortTrade() { 30 | return shortTrade; 31 | } 32 | 33 | public CurrencyPair getCurrencyPair() { 34 | return currencyPair; 35 | } 36 | 37 | public void setCurrencyPair(CurrencyPair currencyPair) { 38 | this.currencyPair = currencyPair; 39 | } 40 | 41 | public BigDecimal getExitTarget() { 42 | return exitTarget; 43 | } 44 | 45 | public void setExitTarget(BigDecimal exitTarget) { 46 | this.exitTarget = exitTarget; 47 | } 48 | 49 | public BigDecimal getEntryBalance() { 50 | return entryBalance; 51 | } 52 | 53 | public void setEntryBalance(BigDecimal entryBalance) { 54 | this.entryBalance = entryBalance; 55 | } 56 | 57 | public OffsetDateTime getEntryTime() { 58 | return entryTime; 59 | } 60 | 61 | public void setEntryTime(OffsetDateTime entryTime) { 62 | this.entryTime = entryTime; 63 | } 64 | 65 | @Override 66 | public boolean equals(Object o) { 67 | if (this == o) return true; 68 | if (!(o instanceof ActivePosition)) return false; 69 | ActivePosition that = (ActivePosition) o; 70 | return Objects.equals(getLongTrade(), that.getLongTrade()) && 71 | Objects.equals(getShortTrade(), that.getShortTrade()) && 72 | Objects.equals(getCurrencyPair(), that.getCurrencyPair()) && 73 | Objects.equals(getExitTarget(), that.getExitTarget()) && 74 | Objects.equals(getEntryBalance(), that.getEntryBalance()) && 75 | Objects.equals(getEntryTime(), that.getEntryTime()); 76 | } 77 | 78 | @Override 79 | public int hashCode() { 80 | return Objects.hash(getLongTrade(), getShortTrade(), getCurrencyPair(), getExitTarget(), getEntryBalance(), getEntryTime()); 81 | } 82 | 83 | @Override 84 | public String toString() { 85 | return "ActivePosition{" + 86 | "longTrade=" + longTrade + 87 | ", shortTrade=" + shortTrade + 88 | ", currencyPair=" + currencyPair + 89 | ", exitTarget=" + exitTarget + 90 | ", entryBalance=" + entryBalance + 91 | ", entryTime=" + entryTime + 92 | '}'; 93 | } 94 | 95 | public static class Trade { 96 | private String exchange; 97 | private String orderId; 98 | private BigDecimal volume; 99 | private BigDecimal entry; 100 | 101 | public String getExchange() { 102 | return exchange; 103 | } 104 | 105 | public void setExchange(String exchange) { 106 | this.exchange = exchange; 107 | } 108 | 109 | public void setExchange(Exchange exchange) { 110 | this.exchange = exchange.getExchangeSpecification().getExchangeName(); 111 | } 112 | 113 | public String getOrderId() { 114 | return orderId; 115 | } 116 | 117 | public void setOrderId(String orderId) { 118 | this.orderId = orderId; 119 | } 120 | 121 | public BigDecimal getVolume() { 122 | return volume; 123 | } 124 | 125 | public void setVolume(BigDecimal volume) { 126 | this.volume = volume; 127 | } 128 | 129 | public BigDecimal getEntry() { 130 | return entry; 131 | } 132 | 133 | public void setEntry(BigDecimal entry) { 134 | this.entry = entry; 135 | } 136 | 137 | @Override 138 | public boolean equals(Object o) { 139 | if (this == o) return true; 140 | if (!(o instanceof Trade)) return false; 141 | Trade trade = (Trade) o; 142 | return Objects.equals(getExchange(), trade.getExchange()) && 143 | Objects.equals(getOrderId(), trade.getOrderId()) && 144 | Objects.equals(getVolume(), trade.getVolume()) && 145 | Objects.equals(getEntry(), trade.getEntry()); 146 | } 147 | 148 | @Override 149 | public int hashCode() { 150 | return Objects.hash(getExchange(), getOrderId(), getVolume(), getEntry()); 151 | } 152 | 153 | @Override 154 | public String toString() { 155 | return "Trade{" + 156 | "exchange='" + exchange + '\'' + 157 | ", orderId='" + orderId + '\'' + 158 | ", volume=" + volume + 159 | ", entry=" + entry + 160 | '}'; 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/ArbitrageLog.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import org.apache.commons.text.StringEscapeUtils; 4 | 5 | import java.math.BigDecimal; 6 | import java.time.OffsetDateTime; 7 | 8 | /** 9 | * This class represents each row in the csv file. 10 | * There are some constrains. All variables must be declared in the same order as they are parsed into CSV 11 | * in the toCsv() method and vice versa. The same rule applies to the headers (csvHeaders). 12 | * 13 | * So, every time you add a new field variable (that you want to be included in the csv) you must also add it 14 | * to the toCsv method in the same order as it is declared. 15 | * 16 | * For retrocompatibility, in case you need to add a new field, it should always be added at the end. 17 | */ 18 | public class ArbitrageLog { 19 | private String shortExchange; 20 | private BigDecimal shortSpread; 21 | private BigDecimal shortSlip; 22 | private BigDecimal shortAmount; 23 | private String shortCurrency; 24 | 25 | private String longExchange; 26 | private BigDecimal longSpread; 27 | private BigDecimal longSlip; 28 | private BigDecimal longAmount; 29 | private String longCurrency; 30 | 31 | private BigDecimal profit; 32 | private OffsetDateTime timestamp; 33 | 34 | public String getShortExchange() { 35 | return shortExchange; 36 | } 37 | 38 | public BigDecimal getShortSpread() { 39 | return shortSpread; 40 | } 41 | 42 | public BigDecimal getShortSlip() { 43 | return shortSlip; 44 | } 45 | 46 | public BigDecimal getShortAmount() { 47 | return shortAmount; 48 | } 49 | 50 | public String getShortCurrency() { 51 | return shortCurrency; 52 | } 53 | 54 | public String getLongExchange() { 55 | return longExchange; 56 | } 57 | 58 | public BigDecimal getLongSpread() { 59 | return longSpread; 60 | } 61 | 62 | public BigDecimal getLongSlip() { 63 | return longSlip; 64 | } 65 | 66 | public BigDecimal getLongAmount() { 67 | return longAmount; 68 | } 69 | 70 | public String getLongCurrency() { 71 | return longCurrency; 72 | } 73 | 74 | public BigDecimal getProfit() { 75 | return profit; 76 | } 77 | 78 | public OffsetDateTime getTimestamp() { 79 | return timestamp; 80 | } 81 | 82 | public String csvHeaders() { 83 | return "\"shortExchange\"," + 84 | "\"shortSpread\"," + 85 | "\"shortSlip\"," + 86 | "\"shortAmount\"," + 87 | "\"shortCurrency\"," + 88 | "\"longExchange\"," + 89 | "\"longSpread\"," + 90 | "\"longSlip\"," + 91 | "\"longAmount\"," + 92 | "\"longCurrency\"," + 93 | "\"profit\"," + 94 | "\"timestamp\"" + 95 | "\n"; 96 | } 97 | 98 | public String toCsv() { 99 | return "\"" + 100 | StringEscapeUtils.escapeCsv(shortExchange) + 101 | "\",\"" + 102 | StringEscapeUtils.escapeCsv(shortSpread.toPlainString()) + 103 | "\",\"" + 104 | StringEscapeUtils.escapeCsv(shortSlip.toPlainString()) + 105 | "\",\"" + 106 | StringEscapeUtils.escapeCsv(shortAmount.toPlainString()) + 107 | "\",\"" + 108 | StringEscapeUtils.escapeCsv(shortCurrency) + 109 | "\",\"" + 110 | StringEscapeUtils.escapeCsv(longExchange) + 111 | "\",\"" + 112 | StringEscapeUtils.escapeCsv(longSpread.toPlainString()) + 113 | "\",\"" + 114 | StringEscapeUtils.escapeCsv(longSlip.toPlainString()) + 115 | "\",\"" + 116 | StringEscapeUtils.escapeCsv(longAmount.toPlainString()) + 117 | "\",\"" + 118 | StringEscapeUtils.escapeCsv(longCurrency) + 119 | "\",\"" + 120 | StringEscapeUtils.escapeCsv(profit.toPlainString()) + 121 | "\",\"" + 122 | StringEscapeUtils.escapeCsv(timestamp.toString()) + 123 | "\"" + 124 | "\n"; 125 | } 126 | 127 | public static final class ArbitrageLogBuilder { 128 | private String shortExchange; 129 | private BigDecimal shortSpread; 130 | private BigDecimal shortSlip; 131 | private BigDecimal shortAmount; 132 | private String shortCurrency; 133 | private String longExchange; 134 | private BigDecimal longSpread; 135 | private BigDecimal longSlip; 136 | private BigDecimal longAmount; 137 | private String longCurrency; 138 | private BigDecimal profit; 139 | private OffsetDateTime timestamp; 140 | 141 | public static ArbitrageLogBuilder builder() { 142 | return new ArbitrageLogBuilder(); 143 | } 144 | 145 | public ArbitrageLogBuilder withShortExchange(String shortExchange) { 146 | this.shortExchange = shortExchange; 147 | return this; 148 | } 149 | 150 | public ArbitrageLogBuilder withShortSpread(BigDecimal shortSpread) { 151 | this.shortSpread = shortSpread; 152 | return this; 153 | } 154 | 155 | public ArbitrageLogBuilder withShortSlip(BigDecimal shortSlip) { 156 | this.shortSlip = shortSlip; 157 | return this; 158 | } 159 | 160 | public ArbitrageLogBuilder withShortAmount(BigDecimal shortAmount) { 161 | this.shortAmount = shortAmount; 162 | return this; 163 | } 164 | 165 | public ArbitrageLogBuilder withShortCurrency(String shortCurrency) { 166 | this.shortCurrency = shortCurrency; 167 | return this; 168 | } 169 | 170 | public ArbitrageLogBuilder withLongExchange(String longExchange) { 171 | this.longExchange = longExchange; 172 | return this; 173 | } 174 | 175 | public ArbitrageLogBuilder withLongSpread(BigDecimal longSpread) { 176 | this.longSpread = longSpread; 177 | return this; 178 | } 179 | 180 | public ArbitrageLogBuilder withLongSlip(BigDecimal longSlip) { 181 | this.longSlip = longSlip; 182 | return this; 183 | } 184 | 185 | public ArbitrageLogBuilder withLongAmount(BigDecimal longAmount) { 186 | this.longAmount = longAmount; 187 | return this; 188 | } 189 | 190 | public ArbitrageLogBuilder withLongCurrency(String longCurrency) { 191 | this.longCurrency = longCurrency; 192 | return this; 193 | } 194 | 195 | public ArbitrageLogBuilder withProfit(BigDecimal profit) { 196 | this.profit = profit; 197 | return this; 198 | } 199 | 200 | public ArbitrageLogBuilder withTimestamp(OffsetDateTime timestamp) { 201 | this.timestamp = timestamp; 202 | return this; 203 | } 204 | 205 | public ArbitrageLog build() { 206 | ArbitrageLog arbitrageLog = new ArbitrageLog(); 207 | 208 | arbitrageLog.longSpread = this.longSpread; 209 | arbitrageLog.shortSpread = this.shortSpread; 210 | arbitrageLog.shortSlip = this.shortSlip; 211 | arbitrageLog.longAmount = this.longAmount; 212 | arbitrageLog.profit = this.profit; 213 | arbitrageLog.longExchange = this.longExchange; 214 | arbitrageLog.shortCurrency = this.shortCurrency; 215 | arbitrageLog.shortExchange = this.shortExchange; 216 | arbitrageLog.longCurrency = this.longCurrency; 217 | arbitrageLog.longSlip = this.longSlip; 218 | arbitrageLog.timestamp = this.timestamp; 219 | arbitrageLog.shortAmount = this.shortAmount; 220 | 221 | return arbitrageLog; 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/ExchangeFee.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.jetbrains.annotations.Nullable; 5 | 6 | import java.math.BigDecimal; 7 | import java.util.Objects; 8 | import java.util.Optional; 9 | 10 | /** 11 | * This class is a placeholder for the different fee amounts. The {@link ExchangeFee#tradeFee} which represents the fee 12 | * charged by the exchange for "long" trades and the {@link ExchangeFee#marginFee} that represents the fee charged by 13 | * the exchange for "short"/margin trades. 14 | */ 15 | public class ExchangeFee { 16 | private final BigDecimal tradeFee; 17 | private final BigDecimal marginFee; 18 | 19 | public ExchangeFee(@NotNull BigDecimal tradeFee, @Nullable BigDecimal marginFee) { 20 | Objects.requireNonNull(tradeFee); 21 | 22 | this.tradeFee = tradeFee; 23 | this.marginFee = marginFee; 24 | } 25 | 26 | /** 27 | * The fee amount charged for a margin trade, for example when you borrow a coin to sell at a later point. 28 | * If the exchange is not set as a margin exchange then marginFee may be null. So here we return an Optional 29 | * to 30 | * @return an {@link Optional} of type {@link BigDecimal} with the margin fee or empty if no marginFee is set. 31 | */ 32 | public Optional getMarginFee() { 33 | return Optional.ofNullable(marginFee); 34 | } 35 | 36 | /** 37 | * The fee amount charged for a long trade. For example when you buy a coin. 38 | * @return the trade fee 39 | */ 40 | public BigDecimal getTradeFee() { 41 | return tradeFee; 42 | } 43 | 44 | /** 45 | * The combined fee rate for this exchange. For non-margin exchanges this will simply return the trade fee. For 46 | * margin exchanges it will return the trade fee plus the margin fee. 47 | * 48 | * @return the combined fees 49 | */ 50 | public BigDecimal getTotalFee() { 51 | if (marginFee != null) { 52 | return tradeFee.add(marginFee); 53 | } 54 | 55 | return tradeFee; 56 | } 57 | 58 | @Override 59 | public String toString() { 60 | return "ExchangeFee{" + 61 | "tradeFee=" + tradeFee + 62 | ", marginFee=" + marginFee + 63 | '}'; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/ExitTradeVolume.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import com.agonyforge.arbitrader.config.FeeComputation; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.math.BigDecimal; 8 | import java.math.RoundingMode; 9 | 10 | public class ExitTradeVolume extends TradeVolume { 11 | 12 | private static final Logger LOGGER = LoggerFactory.getLogger(TradeVolume.class); 13 | 14 | ExitTradeVolume(FeeComputation longFeeComputation, FeeComputation shortFeeComputation, BigDecimal entryLongOrderVolume, BigDecimal entryShortOrderVolume, ExchangeFee longFee, ExchangeFee shortFee, int longScale, int shortScale) { 15 | this.longFeeComputation=longFeeComputation; 16 | this.shortFeeComputation=shortFeeComputation; 17 | 18 | if(longFeeComputation == FeeComputation.SERVER) { 19 | this.longFee=longFee.getTotalFee(); 20 | this.longBaseFee=BigDecimal.ZERO; 21 | } else { 22 | this.longFee= getFeeAdjustedForBuy(FeeComputation.CLIENT, longFee, longScale); 23 | this.longBaseFee = longFee.getTotalFee(); 24 | } 25 | if(shortFeeComputation == FeeComputation.SERVER) { 26 | this.shortFee=shortFee.getTotalFee(); 27 | this.shortBaseFee=BigDecimal.ZERO; 28 | } else { 29 | this.shortFee = getFeeAdjustedForSell(FeeComputation.CLIENT, shortFee, longScale); 30 | this.shortBaseFee = shortFee.getTotalFee(); 31 | } 32 | this.longScale=longScale; 33 | this.shortScale=shortScale; 34 | 35 | this.longVolume = entryLongOrderVolume.subtract(getBuyBaseFees(longFeeComputation, entryLongOrderVolume, longBaseFee, true)); 36 | this.shortVolume = entryShortOrderVolume.add(getSellBaseFees(shortFeeComputation, entryShortOrderVolume, shortBaseFee, true)); 37 | 38 | LOGGER.debug("Instantiate ExitTradeVolume with longVolume {} and shortVolume {}, for parameters: \n" + 39 | "longFeeComputation: {}|shortFeeComputation: {}|longFee: {}|shortFee: {}|longScale: {}|shortScale: {}", 40 | this.longVolume.toPlainString(), 41 | this.shortVolume.toPlainString(), 42 | longFeeComputation, 43 | shortFeeComputation, 44 | longFee, 45 | shortFee, 46 | longScale, 47 | shortScale); 48 | 49 | this.longOrderVolume=longVolume; 50 | this.shortOrderVolume=shortVolume; 51 | } 52 | 53 | @Override 54 | public void adjustOrderVolume(String longExchangeName, String shortExchangeName, BigDecimal longAmountStepSize, BigDecimal shortAmountStepSize) { 55 | 56 | if(longFeeComputation == FeeComputation.CLIENT && longAmountStepSize != null) { 57 | throw new IllegalArgumentException("Long exchange FeeComputation.CLIENT and amountStepSize are not compatible."); 58 | } 59 | 60 | if(shortFeeComputation == FeeComputation.CLIENT && shortAmountStepSize != null) { 61 | throw new IllegalArgumentException("Short exchange FeeComputation.CLIENT and amountStepSize are not compatible."); 62 | } 63 | 64 | if(longFeeComputation == FeeComputation.SERVER) { 65 | BigDecimal scaledVolume = this.longVolume.setScale(longScale, RoundingMode.HALF_EVEN); 66 | if(longVolume.scale() > longScale) { 67 | LOGGER.error("{}: long ordered volume {} does not match the scale {}.", 68 | longExchangeName, 69 | longOrderVolume, 70 | longScale); 71 | throw new IllegalArgumentException(); 72 | } 73 | this.longOrderVolume=scaledVolume; 74 | if(longAmountStepSize != null) { 75 | BigDecimal roundedVolume = roundByStep(longOrderVolume, longAmountStepSize); 76 | if (roundedVolume.compareTo(longOrderVolume) != 0) { 77 | LOGGER.error("{}: long ordered volume {} does not match amount step size {}.", 78 | longExchangeName, 79 | longOrderVolume, 80 | longAmountStepSize); 81 | throw new IllegalArgumentException("Long exchange ordered volume does not match the longAmountStepSize."); 82 | } 83 | } 84 | } 85 | 86 | if(shortFeeComputation == FeeComputation.SERVER) { 87 | BigDecimal scaledVolume = this.shortVolume.setScale(shortScale, RoundingMode.HALF_EVEN); 88 | if(shortVolume.scale() > shortScale) { 89 | LOGGER.error("{}: long ordered volume {} does not match the scale {}.", 90 | shortExchangeName, 91 | shortOrderVolume, 92 | shortScale); 93 | throw new IllegalArgumentException(); 94 | } 95 | this.shortOrderVolume=scaledVolume; 96 | if(shortAmountStepSize != null) { 97 | BigDecimal roundedVolume = roundByStep(shortOrderVolume,shortAmountStepSize); 98 | if(roundedVolume.compareTo(shortOrderVolume) != 0) { 99 | LOGGER.error("{}: short ordered volume {} does not match amount step size {}.", 100 | shortExchangeName, 101 | shortOrderVolume, 102 | shortAmountStepSize); 103 | throw new IllegalArgumentException(); 104 | } 105 | } 106 | } 107 | 108 | //We need to add fees for exchanges where feeComputation is set to CLIENT 109 | // The order volumes will be used to pass the orders after step size and rounding 110 | BigDecimal longBaseFees = getSellBaseFees(longFeeComputation,longVolume,longBaseFee,false); 111 | this.longOrderVolume = longVolume.subtract(longBaseFees); 112 | BigDecimal shortBaseFees = getBuyBaseFees(shortFeeComputation, shortVolume, shortBaseFee, false); 113 | this.shortOrderVolume = shortVolume.add(shortBaseFees); 114 | 115 | if(longFeeComputation == FeeComputation.CLIENT) { 116 | LOGGER.info("{} fees are computed in the client: {} - {} = {}", 117 | longExchangeName, 118 | longOrderVolume, 119 | longBaseFees, 120 | longVolume); 121 | } 122 | 123 | if(shortFeeComputation == FeeComputation.CLIENT) { 124 | LOGGER.info("{} fees are computed in the client: {} - {} = {}", 125 | shortExchangeName, 126 | shortOrderVolume, 127 | shortBaseFees, 128 | shortVolume); 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/Spread.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import org.knowm.xchange.Exchange; 4 | import org.knowm.xchange.currency.CurrencyPair; 5 | import org.knowm.xchange.dto.marketdata.Ticker; 6 | 7 | import java.math.BigDecimal; 8 | 9 | /** 10 | * Similar to a TradeCombination, but with additional information required to determine whether we should trade 11 | * or not. 12 | */ 13 | public class Spread { 14 | private final CurrencyPair currencyPair; 15 | private final Exchange longExchange; 16 | private final Exchange shortExchange; 17 | private final Ticker longTicker; 18 | private final Ticker shortTicker; 19 | private final BigDecimal in; 20 | private final BigDecimal out; 21 | 22 | public Spread( 23 | CurrencyPair currencyPair, 24 | Exchange longExchange, 25 | Exchange shortExchange, 26 | Ticker longTicker, 27 | Ticker shortTicker, 28 | BigDecimal in, 29 | BigDecimal out) { 30 | 31 | this.currencyPair = currencyPair; 32 | this.longExchange = longExchange; 33 | this.shortExchange = shortExchange; 34 | this.longTicker = longTicker; 35 | this.shortTicker = shortTicker; 36 | this.in = in; 37 | this.out = out; 38 | } 39 | 40 | public CurrencyPair getCurrencyPair() { 41 | return currencyPair; 42 | } 43 | 44 | public Exchange getLongExchange() { 45 | return longExchange; 46 | } 47 | 48 | public Exchange getShortExchange() { 49 | return shortExchange; 50 | } 51 | 52 | public Ticker getLongTicker() { 53 | return longTicker; 54 | } 55 | 56 | public Ticker getShortTicker() { 57 | return shortTicker; 58 | } 59 | 60 | public BigDecimal getIn() { 61 | return in; 62 | } 63 | 64 | public BigDecimal getOut() { 65 | return out; 66 | } 67 | 68 | @Override 69 | public String toString() { 70 | return String.format("%s/%s %s %f/%f", 71 | longExchange.getExchangeSpecification().getExchangeName(), 72 | shortExchange.getExchangeSpecification().getExchangeName(), 73 | currencyPair, 74 | in, 75 | out); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/TickerEvent.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import org.knowm.xchange.Exchange; 4 | import org.knowm.xchange.dto.marketdata.Ticker; 5 | import org.springframework.context.ApplicationEvent; 6 | 7 | /** 8 | * An event generated when we receive a ticker from an exchange. 9 | */ 10 | public class TickerEvent extends ApplicationEvent { 11 | 12 | private final Ticker ticker; 13 | private final Exchange exchange; 14 | 15 | /** 16 | * Create a new {@code ApplicationEvent}. 17 | * 18 | * @param ticker the object on which the event initially occurred or with 19 | * which the event is associated (never {@code null}) 20 | */ 21 | public TickerEvent(Ticker ticker, Exchange exchange) { 22 | super(ticker); 23 | this.ticker = ticker; 24 | this.exchange = exchange; 25 | } 26 | 27 | public Ticker getTicker() { 28 | return ticker; 29 | } 30 | 31 | public Exchange getExchange() { 32 | return exchange; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/model/TradeCombination.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import org.knowm.xchange.Exchange; 4 | import org.knowm.xchange.currency.CurrencyPair; 5 | 6 | import java.util.Objects; 7 | 8 | /** 9 | * Represents the combination of two exchanges and a currency pair that we could trade on. The fundamental action 10 | * that Arbitrader does is make two opposing trades in one currency. 11 | */ 12 | public class TradeCombination { 13 | private final Exchange longExchange; 14 | private final Exchange shortExchange; 15 | private final CurrencyPair currencyPair; 16 | 17 | public TradeCombination(Exchange longExchange, Exchange shortExchange, CurrencyPair currencyPair) { 18 | this.longExchange = longExchange; 19 | this.shortExchange = shortExchange; 20 | this.currencyPair = currencyPair; 21 | } 22 | 23 | public Exchange getLongExchange() { 24 | return longExchange; 25 | } 26 | 27 | public Exchange getShortExchange() { 28 | return shortExchange; 29 | } 30 | 31 | public CurrencyPair getCurrencyPair() { 32 | return currencyPair; 33 | } 34 | 35 | @Override 36 | public boolean equals(Object o) { 37 | if (this == o) return true; 38 | if (!(o instanceof TradeCombination)) return false; 39 | TradeCombination that = (TradeCombination) o; 40 | return Objects.equals(getLongExchange(), that.getLongExchange()) && 41 | Objects.equals(getShortExchange(), that.getShortExchange()) && 42 | Objects.equals(getCurrencyPair(), that.getCurrencyPair()); 43 | } 44 | 45 | @Override 46 | public int hashCode() { 47 | return Objects.hash(getLongExchange(), getShortExchange(), getCurrencyPair()); 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return String.format("%s/%s %s", 53 | longExchange.getExchangeSpecification().getExchangeName(), 54 | shortExchange.getExchangeSpecification().getExchangeName(), 55 | currencyPair.toString()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/paper/PaperAccountService.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.paper; 2 | 3 | import com.agonyforge.arbitrader.config.PaperConfiguration; 4 | import com.agonyforge.arbitrader.service.ExchangeService; 5 | import org.knowm.xchange.currency.Currency; 6 | import org.knowm.xchange.currency.CurrencyPair; 7 | import org.knowm.xchange.dto.account.*; 8 | import org.knowm.xchange.exceptions.FundsExceededException; 9 | import org.knowm.xchange.instrument.Instrument; 10 | import org.knowm.xchange.service.account.AccountService; 11 | 12 | import java.io.IOException; 13 | import java.math.BigDecimal; 14 | import java.util.*; 15 | import java.util.stream.Collectors; 16 | 17 | public class PaperAccountService implements AccountService { 18 | 19 | private PaperExchange paperExchange; 20 | private final AccountService accountService; 21 | private final ExchangeService exchangeService; 22 | private Map balances; 23 | 24 | 25 | public PaperAccountService (PaperExchange paperExchange, AccountService accountService, Currency homeCurrency, ExchangeService exchangeService, PaperConfiguration paper) { 26 | this.paperExchange = paperExchange; 27 | this.accountService=accountService; 28 | this.balances=new HashMap<>(); 29 | this.exchangeService=exchangeService; 30 | BigDecimal initialBalance = paper.getInitialBalance() != null ? paper.getInitialBalance() : new BigDecimal("100"); 31 | putCoin(homeCurrency,initialBalance); 32 | } 33 | 34 | public Map getBalances() { 35 | return balances; 36 | } 37 | 38 | public BigDecimal getBalance(Currency currency) { 39 | if(balances.containsKey(currency)) 40 | return balances.get(currency); 41 | return BigDecimal.ZERO; 42 | } 43 | 44 | public void putCoin(Currency currency, BigDecimal amount) { 45 | if(amount.compareTo(BigDecimal.ZERO) == 0) { 46 | //DO NOTHING 47 | } if(!balances.containsKey(currency)) { 48 | balances.put(currency, amount); 49 | } else if (!exchangeService.getExchangeMetadata(paperExchange).getMargin() && getBalance(currency).add(amount).compareTo(BigDecimal.ZERO) < 0){ 50 | throw new FundsExceededException(); 51 | } else if (getBalance(currency).add(amount).compareTo(BigDecimal.ZERO) == 0){ 52 | balances.remove(currency); 53 | } else { 54 | balances.put(currency,getBalance(currency).add(amount)); 55 | } 56 | } 57 | 58 | public AccountInfo getAccountInfo() { 59 | List wallets = new ArrayList<>(); 60 | List walletBalances = this.balances.entrySet().stream().map(entry -> new Balance( 61 | entry.getKey(), 62 | entry.getValue(), 63 | entry.getValue(), 64 | new BigDecimal(0))).collect(Collectors.toList()); 65 | wallets.add(Wallet.Builder.from(walletBalances).id(UUID.randomUUID().toString()).build()); 66 | return new AccountInfo (wallets); 67 | } 68 | 69 | public Map getDynamicTradingFeesByInstrument() throws IOException { 70 | return accountService.getDynamicTradingFeesByInstrument(); 71 | } 72 | 73 | public Map getDynamicTradingFees() throws IOException { 74 | return accountService.getDynamicTradingFees(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/paper/PaperExchange.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.paper; 2 | 3 | import com.agonyforge.arbitrader.config.PaperConfiguration; 4 | import com.agonyforge.arbitrader.service.ExchangeService; 5 | import com.agonyforge.arbitrader.service.TickerService; 6 | import org.knowm.xchange.Exchange; 7 | import org.knowm.xchange.ExchangeSpecification; 8 | import org.knowm.xchange.currency.Currency; 9 | import org.knowm.xchange.currency.CurrencyPair; 10 | import org.knowm.xchange.dto.meta.ExchangeMetaData; 11 | import org.knowm.xchange.exceptions.ExchangeException; 12 | import org.knowm.xchange.service.account.AccountService; 13 | import org.knowm.xchange.service.marketdata.MarketDataService; 14 | import org.knowm.xchange.service.trade.TradeService; 15 | import si.mazi.rescu.SynchronizedValueFactory; 16 | 17 | import java.io.IOException; 18 | import java.util.List; 19 | 20 | public class PaperExchange implements Exchange { 21 | 22 | private final Exchange realExchange; 23 | private final PaperTradeService tradeService; 24 | private final PaperAccountService accountService; 25 | 26 | public PaperExchange(Exchange exchange, Currency homeCurrency, TickerService tickerService, ExchangeService exchangeService, PaperConfiguration paper) { 27 | this.realExchange =exchange; 28 | this.tradeService=new PaperTradeService(this, exchange.getTradeService(), tickerService, exchangeService, paper); 29 | this.accountService=new PaperAccountService(this, exchange.getAccountService(),homeCurrency, exchangeService, paper); 30 | } 31 | 32 | public PaperTradeService getPaperTradeService() { 33 | return tradeService; 34 | } 35 | 36 | public PaperAccountService getPaperAccountService() { 37 | return accountService; 38 | } 39 | 40 | @Override 41 | public ExchangeSpecification getExchangeSpecification() { 42 | return realExchange.getExchangeSpecification(); 43 | } 44 | 45 | @Override 46 | public ExchangeMetaData getExchangeMetaData() { 47 | return realExchange.getExchangeMetaData(); 48 | } 49 | 50 | @Override 51 | public List getExchangeSymbols() { 52 | return realExchange.getExchangeSymbols(); 53 | } 54 | 55 | @Override 56 | public SynchronizedValueFactory getNonceFactory() { 57 | return realExchange.getNonceFactory(); 58 | } 59 | 60 | @Override 61 | public ExchangeSpecification getDefaultExchangeSpecification() { 62 | return realExchange.getDefaultExchangeSpecification(); 63 | } 64 | 65 | @Override 66 | public void applySpecification(ExchangeSpecification exchangeSpecification) { 67 | realExchange.applySpecification(exchangeSpecification); 68 | } 69 | 70 | @Override 71 | public MarketDataService getMarketDataService() { 72 | return realExchange.getMarketDataService(); 73 | } 74 | 75 | @Override 76 | public TradeService getTradeService() { 77 | return tradeService; 78 | } 79 | 80 | @Override 81 | public AccountService getAccountService() { 82 | return accountService; 83 | } 84 | 85 | @Override 86 | public void remoteInit() throws IOException, ExchangeException { 87 | realExchange.remoteInit(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/paper/PaperStreamExchange.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.paper; 2 | 3 | import com.agonyforge.arbitrader.config.PaperConfiguration; 4 | import com.agonyforge.arbitrader.service.ExchangeService; 5 | import com.agonyforge.arbitrader.service.TickerService; 6 | import info.bitrich.xchangestream.core.ProductSubscription; 7 | import info.bitrich.xchangestream.core.StreamingExchange; 8 | import info.bitrich.xchangestream.core.StreamingMarketDataService; 9 | import io.reactivex.Completable; 10 | import org.knowm.xchange.ExchangeSpecification; 11 | import org.knowm.xchange.currency.Currency; 12 | import org.knowm.xchange.currency.CurrencyPair; 13 | import org.knowm.xchange.dto.meta.ExchangeMetaData; 14 | import org.knowm.xchange.exceptions.ExchangeException; 15 | import si.mazi.rescu.SynchronizedValueFactory; 16 | 17 | import java.io.IOException; 18 | import java.util.List; 19 | 20 | public class PaperStreamExchange extends PaperExchange implements StreamingExchange { 21 | private final StreamingExchange realExchange; 22 | 23 | public PaperStreamExchange(StreamingExchange realExchange, Currency homeCurrency, TickerService tickerService, ExchangeService exchangeService, PaperConfiguration paperConfiguration) { 24 | super(realExchange, homeCurrency, tickerService, exchangeService, paperConfiguration); 25 | this.realExchange = realExchange; 26 | } 27 | 28 | @Override 29 | public Completable connect(ProductSubscription... args) { 30 | return realExchange.connect(args); 31 | } 32 | 33 | @Override 34 | public Completable disconnect() { 35 | return realExchange.disconnect(); 36 | } 37 | 38 | @Override 39 | public boolean isAlive() { 40 | return realExchange.isAlive(); 41 | } 42 | 43 | @Override 44 | public StreamingMarketDataService getStreamingMarketDataService() { 45 | return realExchange.getStreamingMarketDataService(); 46 | } 47 | 48 | @Override 49 | public void useCompressedMessages(boolean compressedMessages) { 50 | realExchange.useCompressedMessages(compressedMessages); 51 | } 52 | 53 | @Override 54 | public ExchangeSpecification getExchangeSpecification() { 55 | return realExchange.getExchangeSpecification(); 56 | } 57 | 58 | @Override 59 | public ExchangeMetaData getExchangeMetaData() { 60 | return realExchange.getExchangeMetaData(); 61 | } 62 | 63 | @Override 64 | public List getExchangeSymbols() { 65 | return realExchange.getExchangeSymbols(); 66 | } 67 | 68 | @Override 69 | public SynchronizedValueFactory getNonceFactory() { 70 | return realExchange.getNonceFactory(); 71 | } 72 | 73 | @Override 74 | public ExchangeSpecification getDefaultExchangeSpecification() { 75 | return realExchange.getDefaultExchangeSpecification(); 76 | } 77 | 78 | @Override 79 | public void applySpecification(ExchangeSpecification exchangeSpecification) { 80 | realExchange.applySpecification(exchangeSpecification); 81 | } 82 | 83 | @Override 84 | public void remoteInit() throws IOException, ExchangeException { 85 | realExchange.remoteInit(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/telegram/TelegramClient.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.telegram; 2 | 3 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 4 | import okhttp3.Call; 5 | import okhttp3.Callback; 6 | import okhttp3.HttpUrl; 7 | import okhttp3.OkHttpClient; 8 | import okhttp3.Request; 9 | import okhttp3.Response; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.stereotype.Component; 14 | 15 | import java.io.IOException; 16 | 17 | @Component 18 | public class TelegramClient { 19 | private static final Logger LOGGER = LoggerFactory.getLogger(TelegramClient.class); 20 | 21 | private final OkHttpClient client; 22 | private final String token; 23 | 24 | public TelegramClient(NotificationConfiguration notificationConfiguration) { 25 | this.client = new OkHttpClient(); 26 | this.token = notificationConfiguration.getTelegram().getToken(); 27 | } 28 | 29 | public void sendMessage(String message, String receiverUserName) { 30 | final HttpUrl url = new HttpUrl.Builder() 31 | .scheme("https") 32 | .host("api.telegram.org") 33 | .addPathSegment("bot" + token) 34 | .addPathSegment("sendMessage") 35 | .addQueryParameter("chat_id", receiverUserName) 36 | .addQueryParameter("text", message) 37 | .build(); 38 | 39 | final Request request = new Request.Builder() 40 | .url(url) 41 | .build(); 42 | 43 | client.newCall(request).enqueue(new Callback() { 44 | @Override 45 | public void onFailure(@NotNull Call call, @NotNull IOException e) { 46 | LOGGER.error("Failed to send message to telegram: ", e); 47 | // Cancel the connection in case it is still active. There is no problem calling cancel() on an 48 | // already canceled connection 49 | call.cancel(); 50 | } 51 | 52 | @Override 53 | public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException { 54 | LOGGER.debug("Message sent to telegram. Response: {}", response); 55 | response.close(); 56 | } 57 | }); 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ticker/ParallelTickerStrategy.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 4 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 5 | import com.agonyforge.arbitrader.service.ExchangeService; 6 | import com.agonyforge.arbitrader.service.TickerService; 7 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 8 | import com.agonyforge.arbitrader.service.model.TickerEvent; 9 | import org.apache.commons.collections4.ListUtils; 10 | import org.knowm.xchange.Exchange; 11 | import org.knowm.xchange.currency.CurrencyPair; 12 | import org.knowm.xchange.dto.marketdata.Ticker; 13 | import org.knowm.xchange.service.marketdata.MarketDataService; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import java.lang.reflect.UndeclaredThrowableException; 18 | import java.util.List; 19 | import java.util.Map; 20 | import java.util.Objects; 21 | import java.util.concurrent.atomic.AtomicInteger; 22 | import java.util.stream.Collectors; 23 | 24 | /** 25 | * A TickerStrategy that fetches each ticker with its own call to the API, but all in parallel. 26 | */ 27 | public class ParallelTickerStrategy implements TickerStrategy { 28 | private static final Logger LOGGER = LoggerFactory.getLogger(ParallelTickerStrategy.class); 29 | 30 | private final NotificationConfiguration notificationConfiguration; 31 | private final ExchangeService exchangeService; 32 | private final ErrorCollectorService errorCollectorService; 33 | private final TickerEventPublisher tickerEventPublisher; 34 | 35 | public ParallelTickerStrategy( 36 | NotificationConfiguration notificationConfiguration, 37 | ErrorCollectorService errorCollectorService, 38 | ExchangeService exchangeService, 39 | TickerEventPublisher tickerEventPublisher) { 40 | 41 | this.notificationConfiguration = notificationConfiguration; 42 | this.errorCollectorService = errorCollectorService; 43 | this.exchangeService = exchangeService; 44 | this.tickerEventPublisher = tickerEventPublisher; 45 | } 46 | 47 | @Override 48 | public void getTickers(Exchange exchange, List currencyPairs, TickerService tickerService) { 49 | MarketDataService marketDataService = exchange.getMarketDataService(); 50 | Integer tickerBatchDelay = getTickerExchangeDelay(exchange); 51 | int tickerPartitionSize = getTickerPartitionSize(exchange) 52 | .getOrDefault("batchSize", Integer.MAX_VALUE); 53 | AtomicInteger i = new AtomicInteger(0); // used to avoid delaying the first batch 54 | long start = System.currentTimeMillis(); 55 | 56 | // partition the list of tickers into batches 57 | List tickers = ListUtils.partition(currencyPairs, tickerPartitionSize) 58 | .stream() 59 | .peek(partition -> { 60 | if (tickerBatchDelay != null && i.getAndIncrement() != 0) { // delay if we need to, to try and avoid rate limiting 61 | try { 62 | LOGGER.debug("Waiting {} ms until next batch...", tickerBatchDelay); 63 | Thread.sleep(tickerBatchDelay); 64 | } catch (InterruptedException e) { 65 | LOGGER.trace("Sleep interrupted"); 66 | } 67 | } 68 | }) 69 | .map(partition -> 70 | partition 71 | // executes the following all in parallel rather than sequentially 72 | .parallelStream() 73 | .map(currencyPair -> { 74 | try { 75 | try { 76 | // get the ticker 77 | Ticker ticker = marketDataService.getTicker(exchangeService.convertExchangePair(exchange, currencyPair)); 78 | 79 | LOGGER.debug("Fetched ticker: {} {} {}/{}", 80 | exchange.getExchangeSpecification().getExchangeName(), 81 | ticker.getInstrument(), 82 | ticker.getBid(), 83 | ticker.getAsk()); 84 | 85 | // and return it 86 | return ticker; 87 | } catch (UndeclaredThrowableException ute) { 88 | // Method proxying in rescu can enclose a real exception in this UTE, so we need to unwrap and re-throw it. 89 | throw ute.getCause(); 90 | } 91 | } catch (Throwable t) { 92 | errorCollectorService.collect(exchange, t); 93 | LOGGER.debug("Unexpected checked exception: " + t.getMessage(), t); 94 | } 95 | 96 | return null; 97 | }) 98 | .filter(Objects::nonNull) // get rid of any nulls we managed to collect 99 | .collect(Collectors.toList()) // gather all the tickers we fetched into a list 100 | ) 101 | .flatMap(List::stream)// turn the lists from all the partitions into a stream 102 | .collect(Collectors.toList()); // collect them all into a single list 103 | 104 | long completion = System.currentTimeMillis() - start; 105 | 106 | // if all of that took too long, print a warning in the logs 107 | if (completion > notificationConfiguration.getLogs().getSlowTickerWarning()) { 108 | LOGGER.warn("Slow Tickers! Fetched {} tickers via parallelStream for {} in {} ms", 109 | tickers.size(), 110 | exchange.getExchangeSpecification().getExchangeName(), 111 | System.currentTimeMillis() - start); 112 | } 113 | 114 | // push ticker into TickerService 115 | tickers.forEach(ticker -> tickerService.putTicker(exchange, ticker)); 116 | 117 | // publish events 118 | tickers.forEach(ticker -> tickerEventPublisher.publishTicker(new TickerEvent(ticker, exchange))); 119 | } 120 | 121 | // return the batchDelay configuration parameter 122 | // you can increase this to slow down if you're getting rate limited 123 | private Integer getTickerExchangeDelay(Exchange exchange) { 124 | return exchangeService.getExchangeMetadata(exchange).getTicker().get("batchDelay"); 125 | } 126 | 127 | // the size of the partition is based on how many tickers we have for this exchange 128 | private Map getTickerPartitionSize(Exchange exchange) { 129 | return exchangeService.getExchangeMetadata(exchange).getTicker(); 130 | } 131 | 132 | @Override 133 | public String toString() { 134 | return "Parallel"; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ticker/SingleCallTickerStrategy.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 4 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 5 | import com.agonyforge.arbitrader.service.ExchangeService; 6 | import com.agonyforge.arbitrader.service.TickerService; 7 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 8 | import com.agonyforge.arbitrader.service.model.TickerEvent; 9 | import org.knowm.xchange.Exchange; 10 | import org.knowm.xchange.currency.CurrencyPair; 11 | import org.knowm.xchange.dto.marketdata.Ticker; 12 | import org.knowm.xchange.service.marketdata.MarketDataService; 13 | import org.knowm.xchange.service.marketdata.params.CurrencyPairsParam; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import java.lang.reflect.UndeclaredThrowableException; 18 | import java.util.List; 19 | import java.util.stream.Collectors; 20 | 21 | /** 22 | * A TickerStrategy implementation that makes one call to get multiple Tickers. 23 | */ 24 | public class SingleCallTickerStrategy implements TickerStrategy { 25 | private static final Logger LOGGER = LoggerFactory.getLogger(SingleCallTickerStrategy.class); 26 | 27 | private final NotificationConfiguration notificationConfiguration; 28 | private final ExchangeService exchangeService; 29 | private final ErrorCollectorService errorCollectorService; 30 | private final TickerEventPublisher tickerEventPublisher; 31 | 32 | public SingleCallTickerStrategy( 33 | NotificationConfiguration notificationConfiguration, 34 | ErrorCollectorService errorCollectorService, 35 | ExchangeService exchangeService, 36 | TickerEventPublisher tickerEventPublisher) { 37 | 38 | this.notificationConfiguration = notificationConfiguration; 39 | this.errorCollectorService = errorCollectorService; 40 | this.exchangeService = exchangeService; 41 | this.tickerEventPublisher = tickerEventPublisher; 42 | } 43 | 44 | @Override 45 | public void getTickers(Exchange exchange, List currencyPairs, TickerService tickerService) { 46 | MarketDataService marketDataService = exchange.getMarketDataService(); 47 | 48 | long start = System.currentTimeMillis(); 49 | 50 | try { 51 | try { 52 | CurrencyPairsParam param = () -> currencyPairs.stream() 53 | .map(currencyPair -> exchangeService.convertExchangePair(exchange, currencyPair)) 54 | .collect(Collectors.toList()); 55 | 56 | // call the service with all our CurrencyPairs as the parameter 57 | List tickers = marketDataService.getTickers(param); 58 | 59 | tickers.forEach(ticker -> LOGGER.debug("Fetched ticker: {} {} {}/{}", 60 | exchange.getExchangeSpecification().getExchangeName(), 61 | ticker.getInstrument(), 62 | ticker.getBid(), 63 | ticker.getAsk())); 64 | 65 | long completion = System.currentTimeMillis() - start; 66 | 67 | // if it's too slow, put a warning in the logs 68 | if (completion > notificationConfiguration.getLogs().getSlowTickerWarning()) { 69 | LOGGER.warn("Slow Tickers! Fetched {} tickers via getTickers() for {} in {} ms", 70 | tickers.size(), 71 | exchange.getExchangeSpecification().getExchangeName(), 72 | System.currentTimeMillis() - start); 73 | } 74 | 75 | // push ticker into TickerService 76 | tickers.forEach(ticker -> tickerService.putTicker(exchange, ticker)); 77 | 78 | // publish events 79 | tickers.forEach(ticker -> tickerEventPublisher.publishTicker(new TickerEvent(ticker, exchange))); 80 | } catch (UndeclaredThrowableException ute) { 81 | // Method proxying in rescu can enclose a real exception in this UTE, so we need to unwrap and re-throw it. 82 | throw ute.getCause(); 83 | } 84 | } catch (Throwable t) { 85 | // collect any errors and show them in a summarized way 86 | errorCollectorService.collect(exchange, t); 87 | LOGGER.debug("Unexpected checked exception: " + t.getMessage(), t); 88 | } 89 | } 90 | 91 | @Override 92 | public String toString() { 93 | return "Single Call"; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ticker/StreamingTickerStrategy.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.service.model.TickerEvent; 4 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 5 | import com.agonyforge.arbitrader.service.ExchangeService; 6 | import com.agonyforge.arbitrader.service.TickerService; 7 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 8 | import info.bitrich.xchangestream.core.ProductSubscription; 9 | import info.bitrich.xchangestream.core.StreamingExchange; 10 | import io.reactivex.disposables.Disposable; 11 | import org.knowm.xchange.Exchange; 12 | import org.knowm.xchange.currency.CurrencyPair; 13 | import org.knowm.xchange.dto.marketdata.Ticker; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import java.util.*; 18 | import java.util.stream.Collectors; 19 | 20 | /** 21 | * A TickerStrategy implementation for streaming exchanges. 22 | */ 23 | public class StreamingTickerStrategy implements TickerStrategy { 24 | private static final Logger LOGGER = LoggerFactory.getLogger(StreamingTickerStrategy.class); 25 | 26 | // we would use this list if we supported disconnecting from streams 27 | @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") 28 | private final List subscriptions = new ArrayList<>(); 29 | private final Map> tickers = new HashMap<>(); 30 | private final ErrorCollectorService errorCollectorService; 31 | private final ExchangeService exchangeService; 32 | private final TickerEventPublisher tickerEventPublisher; 33 | 34 | public StreamingTickerStrategy(ErrorCollectorService errorCollectorService, 35 | ExchangeService exchangeService, 36 | TickerEventPublisher tickerEventPublisher) { 37 | this.errorCollectorService = errorCollectorService; 38 | this.exchangeService = exchangeService; 39 | this.tickerEventPublisher = tickerEventPublisher; 40 | } 41 | 42 | @Override 43 | public void getTickers(Exchange stdExchange, List currencyPairs, TickerService tickerService) { 44 | if (!(stdExchange instanceof StreamingExchange)) { 45 | LOGGER.warn("{} is not a streaming exchange", stdExchange.getExchangeSpecification().getExchangeName()); 46 | return; 47 | } 48 | 49 | StreamingExchange exchange = (StreamingExchange)stdExchange; 50 | 51 | // TODO could the following "if" be the reason for the spurious reconnects? 52 | if (!tickers.containsKey(exchange)) { // we're not receiving prices so we need to (re)connect 53 | ProductSubscription.ProductSubscriptionBuilder builder = ProductSubscription.create(); 54 | 55 | currencyPairs.forEach(pair -> { builder.addTicker(exchangeService.convertExchangePair(exchange, pair)); }); 56 | 57 | // try to subscribe to the websocket 58 | exchange.connect(builder.build()).blockingAwait(); 59 | subscriptions.clear(); // avoid endlessly filling this list up with dead subscriptions 60 | subscriptions.addAll(subscribeAll(exchange, currencyPairs, tickerService)); 61 | } 62 | } 63 | 64 | // listen to websocket messages, populate the ticker map and publish ticker events 65 | private List subscribeAll(StreamingExchange exchange, List currencyPairs, TickerService tickerService) { 66 | return currencyPairs 67 | .stream() 68 | .map(pair -> { 69 | final CurrencyPair currencyPair = exchangeService.convertExchangePair(exchange, pair); 70 | final List tickerArguments = exchangeService.getExchangeMetadata(exchange).getTickerArguments(); 71 | 72 | return exchange.getStreamingMarketDataService().getTicker(currencyPair, tickerArguments.toArray()) 73 | .doOnNext(ticker -> log(exchange, ticker)) 74 | .subscribe( 75 | ticker -> { 76 | tickers.computeIfAbsent(exchange, e -> new HashMap<>()); 77 | 78 | // don't waste time analyzing duplicate tickers 79 | Ticker oldTicker = tickers.get(exchange).get(pair); 80 | 81 | if (oldTicker != null 82 | && oldTicker.getInstrument().equals(ticker.getInstrument()) 83 | && oldTicker.getBid().equals(ticker.getBid()) 84 | && oldTicker.getAsk().equals(ticker.getAsk())) { 85 | return; 86 | } 87 | 88 | // store the ticker in our cache 89 | tickers.get(exchange).put(pair, ticker); 90 | 91 | // store the ticker in the TickerService 92 | tickerService.putTicker(exchange, ticker); 93 | 94 | // publish an event to notify that the tickers have updated 95 | tickerEventPublisher.publishTicker(new TickerEvent(ticker, exchange)); 96 | }, 97 | throwable -> { 98 | // collect errors quietly, but expose them in the debug log 99 | errorCollectorService.collect(exchange, throwable); 100 | LOGGER.debug("Unexpected checked exception: {}", throwable.getMessage(), throwable); 101 | }); 102 | }) 103 | .collect(Collectors.toList()); 104 | } 105 | 106 | // debug logging whenever we get a ticker event 107 | private void log(StreamingExchange exchange, Ticker ticker) { 108 | LOGGER.debug("Received ticker: {} {} {}/{}", 109 | exchange.getExchangeSpecification().getExchangeName(), 110 | ticker.getInstrument(), 111 | ticker.getBid(), 112 | ticker.getAsk()); 113 | } 114 | 115 | @Override 116 | public String toString() { 117 | return "Streaming"; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ticker/TickerStrategy.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.service.TickerService; 4 | import org.knowm.xchange.Exchange; 5 | import org.knowm.xchange.currency.CurrencyPair; 6 | 7 | import java.util.List; 8 | 9 | /** 10 | * A TickerStrategy defines a way of getting a Ticker from an exchange. 11 | */ 12 | public interface TickerStrategy { 13 | /** 14 | * Get a set of Tickers from an Exchange. The TickerStrategy should call 15 | * putTicker() on the TickerService to ensure that the global ticker map 16 | * stays up to date. It should also publish a TickerEvent to notify listeners 17 | * who may be interested in knowing that a new ticker is available. 18 | * 19 | * // TODO this method has evolved to do too many things but I'll untangle it later 20 | * 21 | * @param exchange The Exchange to get Tickers from. 22 | * @param currencyPairs The CurrencyPairs to get Tickers for. 23 | */ 24 | void getTickers(Exchange exchange, List currencyPairs, TickerService tickerService); 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/agonyforge/arbitrader/service/ticker/TickerStrategyProvider.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 4 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 5 | import com.agonyforge.arbitrader.service.ExchangeService; 6 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 7 | import org.springframework.stereotype.Component; 8 | 9 | import javax.inject.Inject; 10 | 11 | /** 12 | * A service for getting TickerStrategy implementations. We need to do it this way because there is a 13 | * circular dependency between ExchangeService and one or more of the TickerStrategy implementations. 14 | */ 15 | @Component 16 | public class TickerStrategyProvider { 17 | 18 | private final ErrorCollectorService errorCollectorService; 19 | private final TickerEventPublisher tickerEventPublisher; 20 | private final NotificationConfiguration notificationConfiguration; 21 | 22 | @Inject 23 | public TickerStrategyProvider(ErrorCollectorService errorCollectorService, 24 | TickerEventPublisher tickerEventPublisher, 25 | NotificationConfiguration notificationConfiguration) { 26 | 27 | this.errorCollectorService = errorCollectorService; 28 | this.tickerEventPublisher = tickerEventPublisher; 29 | this.notificationConfiguration = notificationConfiguration; 30 | } 31 | 32 | /** 33 | * Return a TickerStrategy for streaming exchanges. 34 | * 35 | * @param exchangeService An instance of ExchangeService. 36 | * @return A StreamingTickerStrategy. 37 | */ 38 | public TickerStrategy getStreamingTickerStrategy(ExchangeService exchangeService) { 39 | return new StreamingTickerStrategy(errorCollectorService, exchangeService, tickerEventPublisher); 40 | } 41 | 42 | /** 43 | * Return a TickerStrategy that makes individual calls all in parallel. 44 | * 45 | * @param exchangeService An instance of ExchangeService. 46 | * @return A ParallelTickerStrategy. 47 | */ 48 | public TickerStrategy getParallelTickerStrategy(ExchangeService exchangeService) { 49 | return new ParallelTickerStrategy(notificationConfiguration, errorCollectorService, exchangeService, tickerEventPublisher); 50 | } 51 | 52 | /** 53 | * Return a TickerStrategy that makes a single call to the exchange for all the Tickers. 54 | * 55 | * @param exchangeService An instance of ExchangeService. 56 | * @return A SingleCallTickerStrategy. 57 | */ 58 | public TickerStrategy getSingleCallTickerStrategy(ExchangeService exchangeService) { 59 | return new SingleCallTickerStrategy(notificationConfiguration, errorCollectorService, exchangeService, tickerEventPublisher); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | logging: 2 | level: 3 | si: 4 | mazi: 5 | rescu: ERROR 6 | spring: 7 | jackson: 8 | serialization: 9 | write-dates-as-timestamps: false 10 | banner: 11 | image: 12 | location: src/main/resources/banner.txt 13 | -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | ${AnsiStyle.BOLD}${Ansi.BRIGHT_GREEN} ___ __ _ __ __${AnsiStyle.NORMAL} 3 | ${AnsiStyle.BOLD}${Ansi.BRIGHT_GREEN} / | _____ / /_ (_)/ /_ _____ ____ _ ____/ /___ _____${AnsiStyle.NORMAL} 4 | ${AnsiStyle.BOLD}${Ansi.BRIGHT_GREEN} / /| | / ___// __ \ / // __// ___// __ `// __ // _ \ / ___/${AnsiStyle.NORMAL} 5 | ${AnsiStyle.BOLD}${Ansi.BRIGHT_GREEN} / ___ | / / / /_/ // // /_ / / / /_/ // /_/ // __// /${AnsiStyle.NORMAL} 6 | ${AnsiStyle.BOLD}${Ansi.BRIGHT_GREEN}/_/ |_|/_/ /_.___//_/ \__//_/ \__,_/ \__,_/ \___//_/${AnsiStyle.NORMAL} 7 | ${AnsiStyle.BOLD}${Ansi.BRIGHT_RED}:: ${application.title} :: ${application.formatted-version}${AnsiStyle.NORMAL} 8 | -------------------------------------------------------------------------------- /src/main/resources/discord-appender.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/slack-appender.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/BaseTestCase.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader; 2 | 3 | import org.junit.After; 4 | import org.junit.Before; 5 | import org.mockito.MockitoAnnotations; 6 | 7 | /** 8 | * Mockito deprecated MockitoAnnotations.initMocks() and this is one of the recommended patterns to 9 | * replace it. It's nice because it encapsulates opening and closing the mocks but I'm not thrilled 10 | * about having to have a superclass for everything. We'll see how it goes. 11 | * 12 | * https://www.javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/MockitoAnnotations.html 13 | */ 14 | public class BaseTestCase { 15 | private AutoCloseable closeable; 16 | 17 | @Before 18 | public void openMocks() { 19 | closeable = MockitoAnnotations.openMocks(this); 20 | } 21 | 22 | @After 23 | public void releaseMocks() throws Exception { 24 | closeable.close(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/ExchangeBuilder.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader; 2 | 3 | import com.agonyforge.arbitrader.config.ExchangeConfiguration; 4 | import com.agonyforge.arbitrader.service.ticker.TickerStrategy; 5 | import org.knowm.xchange.Exchange; 6 | import org.knowm.xchange.ExchangeSpecification; 7 | import org.knowm.xchange.currency.Currency; 8 | import org.knowm.xchange.currency.CurrencyPair; 9 | import org.knowm.xchange.dto.Order; 10 | import org.knowm.xchange.dto.account.AccountInfo; 11 | import org.knowm.xchange.dto.account.Balance; 12 | import org.knowm.xchange.dto.account.Wallet; 13 | import org.knowm.xchange.dto.marketdata.OrderBook; 14 | import org.knowm.xchange.dto.marketdata.Ticker; 15 | import org.knowm.xchange.dto.meta.CurrencyMetaData; 16 | import org.knowm.xchange.dto.meta.CurrencyPairMetaData; 17 | import org.knowm.xchange.dto.meta.ExchangeMetaData; 18 | import org.knowm.xchange.dto.trade.LimitOrder; 19 | import org.knowm.xchange.exceptions.NotAvailableFromExchangeException; 20 | import org.knowm.xchange.exceptions.NotYetImplementedForExchangeException; 21 | import org.knowm.xchange.service.account.AccountService; 22 | import org.knowm.xchange.service.marketdata.MarketDataService; 23 | import org.knowm.xchange.service.marketdata.params.Params; 24 | import org.knowm.xchange.service.trade.TradeService; 25 | 26 | import java.io.IOException; 27 | import java.math.BigDecimal; 28 | import java.math.RoundingMode; 29 | import java.util.ArrayList; 30 | import java.util.Collections; 31 | import java.util.Date; 32 | import java.util.HashMap; 33 | import java.util.List; 34 | import java.util.Map; 35 | import java.util.UUID; 36 | 37 | import static com.agonyforge.arbitrader.DecimalConstants.BTC_SCALE; 38 | import static com.agonyforge.arbitrader.DecimalConstants.USD_SCALE; 39 | import static com.agonyforge.arbitrader.service.TradingScheduler.METADATA_KEY; 40 | import static com.agonyforge.arbitrader.service.TradingScheduler.TICKER_STRATEGY_KEY; 41 | import static org.mockito.ArgumentMatchers.any; 42 | import static org.mockito.ArgumentMatchers.eq; 43 | import static org.mockito.Mockito.mock; 44 | import static org.mockito.Mockito.when; 45 | 46 | public class ExchangeBuilder { 47 | public static final int EXCHANGE_METADATA_PRICE_SCALE = 3; // intentionally different than BTC_SCALE to help with assertions 48 | public static final int EXCHANGE_METADATA_VOLUME_SCALE = 4; // intentionally different than BTC_SCALE to help with assertions 49 | 50 | private String name; 51 | private CurrencyPair currencyPair; 52 | private Currency homeCurrency = Currency.USD; 53 | private Integer bids; 54 | private Integer asks; 55 | private List balances = new ArrayList<>(); 56 | private ExchangeMetaData exchangeMetaData = null; 57 | private Exception tickerException = null; 58 | private TradeService tradeService = null; 59 | private TickerStrategy tickerStrategy = null; 60 | private Boolean isGetTickersImplemented = null; 61 | private List tickers = new ArrayList<>(); 62 | private List tradingPairs = new ArrayList<>(); 63 | private Boolean isMarginSupported = false; 64 | 65 | public ExchangeBuilder(String name, CurrencyPair currencyPair) { 66 | this.name = name; 67 | this.currencyPair = currencyPair; 68 | } 69 | 70 | public ExchangeBuilder withMarginSupported(boolean isMarginSupported) { 71 | this.isMarginSupported = isMarginSupported; 72 | 73 | return this; 74 | } 75 | 76 | public ExchangeBuilder withOrderBook(int bids, int asks) { 77 | this.bids = bids; 78 | this.asks = asks; 79 | return this; 80 | } 81 | 82 | public ExchangeBuilder withBalance(Currency currency, BigDecimal amount) { 83 | Balance balance = new Balance.Builder() 84 | .currency(currency) 85 | .available(amount) 86 | .build(); 87 | 88 | balances.add(balance); 89 | 90 | return this; 91 | } 92 | 93 | public ExchangeBuilder withTickers(boolean isGetTickersImplemented, List currencyPairs) { 94 | this.isGetTickersImplemented = isGetTickersImplemented; 95 | this.tradingPairs.addAll(currencyPairs); 96 | 97 | currencyPairs.forEach(currencyPair -> 98 | tickers.add(new Ticker.Builder() 99 | .currencyPair(currencyPair) 100 | .open(new BigDecimal("1000.000")) 101 | .last(new BigDecimal("1001.000")) 102 | .bid(new BigDecimal("1001.000")) 103 | .ask(new BigDecimal("1002.000")) 104 | .high(new BigDecimal("1005.00")) 105 | .low(new BigDecimal("1000.00")) 106 | .vwap(new BigDecimal("1000.50")) 107 | .volume(new BigDecimal("500000.00")) 108 | .quoteVolume(new BigDecimal("600000.00")) 109 | .bidSize(new BigDecimal("400.00")) 110 | .askSize(new BigDecimal("600.00")) 111 | .build()) 112 | ); 113 | 114 | return this; 115 | } 116 | 117 | public ExchangeBuilder withTickers(Exception toThrow) { 118 | this.tickerException = toThrow; 119 | 120 | return this; 121 | } 122 | 123 | public ExchangeBuilder withTickerStrategy(TickerStrategy tickerStrategy) { 124 | this.tickerStrategy = tickerStrategy; 125 | 126 | return this; 127 | } 128 | 129 | public ExchangeBuilder withExchangeMetaData() { 130 | CurrencyPairMetaData currencyPairMetaData = new CurrencyPairMetaData( 131 | new BigDecimal("0.0020"), 132 | new BigDecimal("0.0010"), 133 | new BigDecimal("1000.00000000"), 134 | EXCHANGE_METADATA_PRICE_SCALE, 135 | EXCHANGE_METADATA_VOLUME_SCALE, 136 | null, 137 | Currency.USD); 138 | Map currencyPairMetaDataMap = new HashMap<>(); 139 | 140 | currencyPairMetaDataMap.put(currencyPair, currencyPairMetaData); 141 | 142 | CurrencyMetaData baseMetaData = new CurrencyMetaData(BTC_SCALE, BigDecimal.ZERO); 143 | CurrencyMetaData counterMetaData = new CurrencyMetaData(USD_SCALE, BigDecimal.ZERO); 144 | Map currencyMetaDataMap = new HashMap<>(); 145 | 146 | currencyMetaDataMap.put(currencyPair.base, baseMetaData); 147 | currencyMetaDataMap.put(currencyPair.counter, counterMetaData); 148 | 149 | exchangeMetaData = new ExchangeMetaData( 150 | currencyPairMetaDataMap, 151 | currencyMetaDataMap, 152 | null, 153 | null, 154 | null 155 | ); 156 | 157 | return this; 158 | } 159 | 160 | public ExchangeBuilder withTradeService() throws IOException { 161 | tradeService = mock(TradeService.class); 162 | 163 | LimitOrder order = new LimitOrder( 164 | Order.OrderType.BID, 165 | BigDecimal.TEN, 166 | currencyPair, 167 | UUID.randomUUID().toString(), 168 | new Date(), 169 | new BigDecimal("100.0000") 170 | .add(new BigDecimal("0.001")) 171 | .setScale(BTC_SCALE, RoundingMode.HALF_EVEN)); 172 | 173 | when(tradeService.getOrder(eq("orderId"))).thenReturn(Collections.singleton(order)); 174 | when(tradeService.getOrder(eq("missingOrder"))).thenReturn(Collections.emptyList()); 175 | when(tradeService.getOrder(eq("notAvailable"))).thenThrow(new NotAvailableFromExchangeException("Exchange does not support fetching orders by ID")); 176 | when(tradeService.getOrder(eq("ioe"))).thenThrow(new IOException("Unable to connect to exchange")); 177 | 178 | return this; 179 | } 180 | 181 | public ExchangeBuilder withHomeCurrency(Currency homeCurrency) { 182 | this.homeCurrency = homeCurrency; 183 | 184 | return this; 185 | } 186 | 187 | public Exchange build() throws IOException { 188 | Exchange exchange = mock(Exchange.class); 189 | ExchangeSpecification specification = mock(ExchangeSpecification.class); 190 | ExchangeConfiguration metadata = new ExchangeConfiguration(); 191 | MarketDataService marketDataService = mock(MarketDataService.class); 192 | 193 | metadata.setHomeCurrency(homeCurrency); 194 | metadata.setTradingPairs(tradingPairs); 195 | metadata.setMargin(isMarginSupported); 196 | 197 | when(exchange.getExchangeSpecification()).thenReturn(specification); 198 | when(specification.getExchangeName()).thenReturn(name); 199 | when(specification.getExchangeSpecificParametersItem(METADATA_KEY)).thenReturn(metadata); 200 | when(exchange.getMarketDataService()).thenReturn(marketDataService); 201 | 202 | if (tickerException != null) { 203 | when(marketDataService.getTicker(any())).thenThrow(tickerException); 204 | when(marketDataService.getTickers(any(Params.class))).thenThrow(tickerException); 205 | } 206 | 207 | if (tickers != null && !tickers.isEmpty()) { 208 | tickers.forEach(ticker -> { 209 | try { 210 | when(marketDataService.getTicker(eq(ticker.getCurrencyPair()))).thenReturn(ticker); 211 | } catch (IOException e) { 212 | // nothing to do here if we couldn't build the mock 213 | } 214 | }); 215 | 216 | if (isGetTickersImplemented) { 217 | when(marketDataService.getTickers(any())).thenReturn(tickers); 218 | } else { 219 | when(marketDataService.getTickers(any())).thenThrow(new NotYetImplementedForExchangeException()); 220 | } 221 | } 222 | 223 | if (bids != null || asks != null) { 224 | OrderBook orderBook = new OrderBook( 225 | new Date(), 226 | generateOrders(currencyPair, Order.OrderType.ASK), 227 | generateOrders(currencyPair, Order.OrderType.BID) 228 | ); 229 | 230 | when(marketDataService.getOrderBook(eq(currencyPair))).thenReturn(orderBook); 231 | } 232 | 233 | if (!balances.isEmpty()) { 234 | Wallet wallet = Wallet.Builder.from(balances).build(); 235 | AccountInfo accountInfo = new AccountInfo(wallet); 236 | AccountService accountService = mock(AccountService.class); 237 | 238 | when(accountService.getAccountInfo()).thenReturn(accountInfo); 239 | when(exchange.getAccountService()).thenReturn(accountService); 240 | } 241 | 242 | if (tickerStrategy != null) { 243 | when(specification.getExchangeSpecificParametersItem(TICKER_STRATEGY_KEY)).thenReturn(tickerStrategy); 244 | } 245 | 246 | if (exchangeMetaData != null) { 247 | when(exchange.getExchangeMetaData()).thenReturn(exchangeMetaData); 248 | } 249 | 250 | if (tradeService != null) { 251 | when(exchange.getTradeService()).thenReturn(tradeService); 252 | } 253 | 254 | return exchange; 255 | } 256 | 257 | public static List generateOrders(CurrencyPair currencyPair, Order.OrderType type) { 258 | List orders = new ArrayList<>(); 259 | 260 | for (int i = 0; i < 100; i++) { 261 | orders.add(new LimitOrder( 262 | type, 263 | BigDecimal.TEN, 264 | currencyPair, 265 | UUID.randomUUID().toString(), 266 | new Date(), 267 | new BigDecimal("100.0000") 268 | .add(new BigDecimal(i * 0.001)) 269 | .setScale(BTC_SCALE, RoundingMode.HALF_EVEN))); 270 | } 271 | 272 | if (Order.OrderType.BID.equals(type)) { 273 | Collections.reverse(orders); 274 | } 275 | 276 | return orders; 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/logging/SpringContextSingletonTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.logging; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | import org.mockito.Mock; 6 | import org.mockito.MockitoAnnotations; 7 | import org.springframework.context.ApplicationContext; 8 | 9 | import static org.junit.Assert.assertEquals; 10 | 11 | public class SpringContextSingletonTest { 12 | @Mock 13 | private ApplicationContext applicationContext; 14 | 15 | @SuppressWarnings("FieldCanBeLocal") 16 | private SpringContextSingleton singleton; 17 | 18 | @Before 19 | public void setUp() { 20 | MockitoAnnotations.initMocks(this); 21 | 22 | singleton = new SpringContextSingleton(); 23 | singleton.setup(); 24 | singleton.setApplicationContext(applicationContext); 25 | } 26 | 27 | @Test 28 | public void testGetApplicationContext() { 29 | SpringContextSingleton result = SpringContextSingleton.getInstance(); 30 | 31 | assertEquals(applicationContext, result.getApplicationContext()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/ConditionServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import com.agonyforge.arbitrader.BaseTestCase; 4 | import org.apache.commons.io.FileUtils; 5 | import org.junit.AfterClass; 6 | import org.junit.Before; 7 | import org.junit.Test; 8 | import org.knowm.xchange.Exchange; 9 | import org.knowm.xchange.ExchangeSpecification; 10 | import org.knowm.xchange.currency.CurrencyPair; 11 | import org.mockito.Mock; 12 | 13 | import java.io.File; 14 | import java.io.FileOutputStream; 15 | import java.io.IOException; 16 | import java.io.PrintWriter; 17 | import java.nio.charset.Charset; 18 | import java.time.ZonedDateTime; 19 | import java.time.format.DateTimeFormatter; 20 | 21 | import static com.agonyforge.arbitrader.service.ConditionService.*; 22 | import static org.junit.Assert.*; 23 | import static org.mockito.Mockito.when; 24 | 25 | public class ConditionServiceTest extends BaseTestCase { 26 | private static final String TEST_EXCHANGE_NAME = "Test Exchange"; 27 | 28 | @Mock 29 | private Exchange exchange; 30 | 31 | @Mock 32 | private ExchangeSpecification exchangeSpecification; 33 | 34 | private ConditionService conditionService; 35 | 36 | @AfterClass 37 | public static void afterClass() { 38 | FileUtils.deleteQuietly(new File(FORCE_OPEN)); 39 | FileUtils.deleteQuietly(new File(FORCE_CLOSE)); 40 | FileUtils.deleteQuietly(new File(EXIT_WHEN_IDLE)); 41 | FileUtils.deleteQuietly(new File(STATUS)); 42 | FileUtils.deleteQuietly(new File(BLACKOUT)); 43 | } 44 | 45 | @Before 46 | public void setUp() { 47 | when(exchange.getExchangeSpecification()).thenReturn(exchangeSpecification); 48 | when(exchangeSpecification.getExchangeName()).thenReturn(TEST_EXCHANGE_NAME); 49 | 50 | conditionService = new ConditionService(); 51 | } 52 | 53 | @Test 54 | public void testClearForceOpenConditionIdempotence() { 55 | // it should not break if the condition is already clear 56 | conditionService.clearForceOpenCondition(); 57 | } 58 | 59 | @Test 60 | public void testClearForceOpenCondition() throws IOException { 61 | File forceOpen = new File(FORCE_OPEN); 62 | 63 | assertTrue(forceOpen.createNewFile()); 64 | assertTrue(forceOpen.exists()); 65 | 66 | conditionService.clearForceOpenCondition(); 67 | 68 | assertFalse(forceOpen.exists()); 69 | } 70 | 71 | @Test 72 | public void testCheckForceOpenCondition() throws IOException { 73 | File forceOpen = new File(FORCE_OPEN); 74 | CurrencyPair currencyPair = CurrencyPair.BTC_USD; 75 | String longExchangeName = "CrazyCoinz"; 76 | String shortExchangeName = "CoinBazaar"; 77 | 78 | assertFalse(forceOpen.exists()); 79 | assertFalse(conditionService.isForceOpenCondition(currencyPair, longExchangeName, shortExchangeName)); 80 | 81 | FileUtils.writeStringToFile(forceOpen,"BTC/USD CrazyCoinz/CoinBazaar", Charset.defaultCharset()); 82 | 83 | assertTrue(forceOpen.exists()); 84 | assertTrue(conditionService.isForceOpenCondition(currencyPair, longExchangeName, shortExchangeName)); 85 | 86 | FileUtils.deleteQuietly(forceOpen); 87 | } 88 | 89 | @Test 90 | public void testCheckForceOpenConditionWrongExchange() throws IOException { 91 | File forceOpen = new File(FORCE_OPEN); 92 | CurrencyPair currencyPair = CurrencyPair.BTC_USD; 93 | String longExchangeName = "CoinGuru"; 94 | String shortExchangeName = "CoinBazaar"; 95 | 96 | assertFalse(forceOpen.exists()); 97 | assertFalse(conditionService.isForceOpenCondition(currencyPair, longExchangeName, shortExchangeName)); 98 | 99 | FileUtils.writeStringToFile(forceOpen,"BTC/USD CrazyCoins/CoinBazaar", Charset.defaultCharset()); 100 | 101 | assertTrue(forceOpen.exists()); 102 | assertFalse(conditionService.isForceOpenCondition(currencyPair, longExchangeName, shortExchangeName)); 103 | 104 | FileUtils.deleteQuietly(forceOpen); 105 | } 106 | 107 | @Test 108 | public void testCheckForceOpenConditionWrongPair() throws IOException { 109 | File forceOpen = new File(FORCE_OPEN); 110 | CurrencyPair currencyPair = CurrencyPair.XRP_USD; 111 | String longExchangeName = "CrazyCoinz"; 112 | String shortExchangeName = "CoinBazaar"; 113 | 114 | assertFalse(forceOpen.exists()); 115 | assertFalse(conditionService.isForceOpenCondition(currencyPair, longExchangeName, shortExchangeName)); 116 | 117 | FileUtils.writeStringToFile(forceOpen,"BTC/USD CrazyCoins/CoinBazaar", Charset.defaultCharset()); 118 | 119 | assertTrue(forceOpen.exists()); 120 | assertFalse(conditionService.isForceOpenCondition(currencyPair, longExchangeName, shortExchangeName)); 121 | 122 | FileUtils.deleteQuietly(forceOpen); 123 | } 124 | 125 | @Test 126 | public void testClearForceCloseConditionIdempotence() { 127 | // it should not break if the condition is already clear 128 | conditionService.clearForceCloseCondition(); 129 | } 130 | 131 | @Test 132 | public void testClearForceCloseCondition() throws IOException { 133 | File forceClose = new File(FORCE_CLOSE); 134 | 135 | assertTrue(forceClose.createNewFile()); 136 | assertTrue(forceClose.exists()); 137 | 138 | conditionService.clearForceCloseCondition(); 139 | 140 | assertFalse(forceClose.exists()); 141 | } 142 | 143 | @Test 144 | public void testCheckForceCloseCondition() throws IOException { 145 | File forceClose = new File(FORCE_CLOSE); 146 | 147 | assertFalse(forceClose.exists()); 148 | assertFalse(conditionService.isForceCloseCondition()); 149 | 150 | assertTrue(forceClose.createNewFile()); 151 | 152 | assertTrue(forceClose.exists()); 153 | assertTrue(conditionService.isForceCloseCondition()); 154 | 155 | FileUtils.deleteQuietly(forceClose); 156 | } 157 | 158 | @Test 159 | public void testClearExitWhenIdleConditionIdempotence() { 160 | conditionService.clearExitWhenIdleCondition(); 161 | } 162 | 163 | @Test 164 | public void testClearExitWhenIdleCondition() throws IOException { 165 | File exitWhenIdle = new File(EXIT_WHEN_IDLE); 166 | 167 | assertTrue(exitWhenIdle.createNewFile()); 168 | assertTrue(exitWhenIdle.exists()); 169 | 170 | conditionService.clearExitWhenIdleCondition(); 171 | 172 | assertFalse(exitWhenIdle.exists()); 173 | } 174 | 175 | @Test 176 | public void testCheckExitWhenIdleCondition() throws IOException { 177 | File exitWhenIdle = new File(EXIT_WHEN_IDLE); 178 | 179 | assertFalse(exitWhenIdle.exists()); 180 | assertFalse(conditionService.isExitWhenIdleCondition()); 181 | 182 | assertTrue(exitWhenIdle.createNewFile()); 183 | 184 | assertTrue(exitWhenIdle.exists()); 185 | assertTrue(conditionService.isExitWhenIdleCondition()); 186 | 187 | FileUtils.deleteQuietly(exitWhenIdle); 188 | } 189 | 190 | @Test 191 | public void testClearStatusCondition() throws IOException { 192 | File status = new File(STATUS); 193 | 194 | assertTrue(status.createNewFile()); 195 | assertTrue(status.exists()); 196 | 197 | conditionService.clearStatusCondition(); 198 | 199 | assertFalse(status.exists()); 200 | } 201 | 202 | @Test 203 | public void testStatusCondition() throws IOException { 204 | File status = new File(STATUS); 205 | 206 | assertFalse(status.exists()); 207 | assertFalse(conditionService.isStatusCondition()); 208 | 209 | assertTrue(status.createNewFile()); 210 | 211 | assertTrue(status.exists()); 212 | assertTrue(conditionService.isStatusCondition()); 213 | 214 | FileUtils.deleteQuietly(status); 215 | } 216 | 217 | @Test 218 | public void testNoFileBlackoutCondition() { 219 | File blackoutFile = new File(BLACKOUT); 220 | 221 | assertFalse(blackoutFile.exists()); 222 | assertFalse(conditionService.isBlackoutCondition(exchange)); 223 | } 224 | 225 | @Test 226 | public void testFutureBlackoutBlackoutCondition() throws IOException { 227 | File blackoutFile = new File(BLACKOUT); 228 | PrintWriter writer = new PrintWriter(new FileOutputStream(blackoutFile)); 229 | 230 | // blackout occurs in the future 231 | ZonedDateTime blackoutStart = ZonedDateTime.now().plusHours(1L); 232 | ZonedDateTime blackoutEnd = ZonedDateTime.now().plusHours(2L); 233 | 234 | writer.println(String.format("%s,%s,%s", 235 | TEST_EXCHANGE_NAME, 236 | blackoutStart.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), 237 | blackoutEnd.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))); 238 | writer.close(); 239 | 240 | assertFalse(conditionService.isBlackoutCondition(exchange)); 241 | 242 | FileUtils.deleteQuietly(blackoutFile); 243 | } 244 | 245 | @Test 246 | public void testPastBlackoutBlackoutCondition() throws IOException { 247 | File blackoutFile = new File(BLACKOUT); 248 | PrintWriter writer = new PrintWriter(new FileOutputStream(blackoutFile)); 249 | 250 | // blackout occurs in the past 251 | ZonedDateTime blackoutStart = ZonedDateTime.now().minusHours(2L); 252 | ZonedDateTime blackoutEnd = ZonedDateTime.now().minusHours(1L); 253 | 254 | writer.println(String.format("%s,%s,%s", 255 | TEST_EXCHANGE_NAME, 256 | blackoutStart.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), 257 | blackoutEnd.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))); 258 | writer.close(); 259 | 260 | assertFalse(conditionService.isBlackoutCondition(exchange)); 261 | 262 | FileUtils.deleteQuietly(blackoutFile); 263 | } 264 | 265 | @Test 266 | public void testCurrentBlackoutBlackoutCondition() throws IOException { 267 | File blackoutFile = new File(BLACKOUT); 268 | PrintWriter writer = new PrintWriter(new FileOutputStream(blackoutFile)); 269 | 270 | // blackout is in progress 271 | ZonedDateTime blackoutStart = ZonedDateTime.now().minusHours(1L); 272 | ZonedDateTime blackoutEnd = ZonedDateTime.now().plusHours(1L); 273 | 274 | writer.println(String.format("%s,%s,%s", 275 | TEST_EXCHANGE_NAME, 276 | blackoutStart.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), 277 | blackoutEnd.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))); 278 | writer.close(); 279 | 280 | assertTrue(conditionService.isBlackoutCondition(exchange)); 281 | 282 | FileUtils.deleteQuietly(blackoutFile); 283 | } 284 | 285 | @Test 286 | public void testCurrentBlackoutForOtherExchangeBlackoutCondition() throws IOException { 287 | File blackoutFile = new File(BLACKOUT); 288 | PrintWriter writer = new PrintWriter(new FileOutputStream(blackoutFile)); 289 | 290 | // blackout is in progress 291 | ZonedDateTime blackoutStart = ZonedDateTime.now().minusHours(1L); 292 | ZonedDateTime blackoutEnd = ZonedDateTime.now().plusHours(1L); 293 | 294 | writer.println(String.format("%s,%s,%s", 295 | "Other Exchange", 296 | blackoutStart.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), 297 | blackoutEnd.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))); 298 | writer.close(); 299 | 300 | assertFalse(conditionService.isBlackoutCondition(exchange)); 301 | 302 | FileUtils.deleteQuietly(blackoutFile); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/ErrorCollectorServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import org.junit.Before; 5 | import org.junit.Test; 6 | import org.knowm.xchange.Exchange; 7 | import org.knowm.xchange.currency.CurrencyPair; 8 | import org.mockito.MockitoAnnotations; 9 | 10 | import java.io.IOException; 11 | import java.util.List; 12 | 13 | import static com.agonyforge.arbitrader.service.ErrorCollectorService.HEADER; 14 | import static org.junit.Assert.*; 15 | 16 | public class ErrorCollectorServiceTest { 17 | private static final String EXCHANGE_NAME = "ExceptionalCoins"; 18 | 19 | private Exchange exchange; 20 | 21 | private ErrorCollectorService errorCollectorService; 22 | 23 | @Before 24 | public void setUp() throws IOException { 25 | MockitoAnnotations.initMocks(this); 26 | 27 | exchange = new ExchangeBuilder(EXCHANGE_NAME, CurrencyPair.BTC_USD) 28 | .build(); 29 | 30 | errorCollectorService = new ErrorCollectorService(); 31 | } 32 | 33 | @Test 34 | public void testCollect() { 35 | errorCollectorService.collect(exchange, new NullPointerException("Boom!")); 36 | 37 | List report = errorCollectorService.report(); 38 | 39 | assertEquals(2, report.size()); 40 | assertEquals(HEADER, report.get(0)); 41 | assertEquals(EXCHANGE_NAME + ": NullPointerException Boom! x 1", report.get(1)); 42 | } 43 | 44 | @Test 45 | public void testCollectIncrementsByOne() { 46 | errorCollectorService.collect(exchange, new NullPointerException("Boom!")); 47 | errorCollectorService.collect(exchange, new NullPointerException("Boom!")); 48 | 49 | List report = errorCollectorService.report(); 50 | 51 | assertEquals(2, report.size()); 52 | assertEquals(HEADER, report.get(0)); 53 | assertEquals(EXCHANGE_NAME + ": NullPointerException Boom! x 2", report.get(1)); 54 | } 55 | 56 | @Test 57 | public void testEmptyReport() { 58 | List report = errorCollectorService.report(); 59 | 60 | assertEquals(1, report.size()); 61 | assertEquals(HEADER, report.get(0)); 62 | } 63 | 64 | @Test 65 | public void testIsEmpty() { 66 | assertTrue(errorCollectorService.isEmpty()); 67 | 68 | errorCollectorService.collect(exchange, new NullPointerException()); 69 | 70 | assertFalse(errorCollectorService.isEmpty()); 71 | } 72 | 73 | @Test 74 | public void testClear() { 75 | errorCollectorService.collect(exchange, new NullPointerException()); 76 | 77 | assertFalse(errorCollectorService.isEmpty()); 78 | 79 | errorCollectorService.clear(); 80 | 81 | assertTrue(errorCollectorService.isEmpty()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/ExchangeServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.agonyforge.arbitrader.config.ExchangeConfiguration; 5 | import com.agonyforge.arbitrader.service.cache.ExchangeFeeCache; 6 | import com.agonyforge.arbitrader.service.ticker.TickerStrategyProvider; 7 | import org.junit.Before; 8 | import org.junit.Test; 9 | import org.knowm.xchange.Exchange; 10 | import org.knowm.xchange.currency.Currency; 11 | import org.knowm.xchange.currency.CurrencyPair; 12 | import org.mockito.Mock; 13 | import org.mockito.MockitoAnnotations; 14 | 15 | import java.io.IOException; 16 | 17 | import static org.junit.Assert.assertEquals; 18 | import static org.junit.Assert.assertNotNull; 19 | 20 | public class ExchangeServiceTest { 21 | private Exchange exchange; 22 | 23 | private ExchangeService exchangeService; 24 | 25 | @Mock 26 | private ExchangeFeeCache exchangeFeeCache; 27 | @Mock 28 | private TickerStrategyProvider tickerStrategyProvider; 29 | 30 | @Before 31 | public void setUp() throws IOException { 32 | MockitoAnnotations.initMocks(this); 33 | 34 | exchange = new ExchangeBuilder("CoinFraud", CurrencyPair.BTC_USD) 35 | .withHomeCurrency(Currency.USDT) 36 | .build(); 37 | 38 | exchangeService = new ExchangeService(exchangeFeeCache, tickerStrategyProvider); 39 | } 40 | 41 | @Test 42 | public void testExchangeMetadata() { 43 | ExchangeConfiguration configuration = exchangeService.getExchangeMetadata(exchange); 44 | 45 | assertNotNull(configuration); 46 | } 47 | 48 | @Test 49 | public void testExchangeHomeCurrency() { 50 | Currency homeCurrency = exchangeService.getExchangeHomeCurrency(exchange); 51 | 52 | assertEquals(Currency.USDT, homeCurrency); 53 | } 54 | 55 | @Test 56 | public void testConvertExchangePairBase() { 57 | CurrencyPair currencyPair = CurrencyPair.BTC_USD; 58 | CurrencyPair converted = exchangeService.convertExchangePair(exchange, currencyPair); 59 | 60 | assertEquals(CurrencyPair.BTC_USDT, converted); 61 | } 62 | 63 | @Test 64 | public void testConvertExchangePairCounter() { 65 | CurrencyPair currencyPair = new CurrencyPair("USD", "BTC"); 66 | CurrencyPair converted = exchangeService.convertExchangePair(exchange, currencyPair); 67 | 68 | assertEquals(new CurrencyPair("USDT", "BTC"), converted); 69 | } 70 | 71 | @Test 72 | public void testConvertExchangePairNeither() { 73 | CurrencyPair currencyPair = CurrencyPair.DOGE_BTC; 74 | CurrencyPair converted = exchangeService.convertExchangePair(exchange, currencyPair); 75 | 76 | assertEquals(CurrencyPair.DOGE_BTC, converted); 77 | assertEquals(currencyPair, converted); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/cache/ExchangeBalanceCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.cache; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.agonyforge.arbitrader.BaseTestCase; 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | import org.knowm.xchange.Exchange; 8 | import org.knowm.xchange.currency.CurrencyPair; 9 | 10 | import java.io.IOException; 11 | import java.math.BigDecimal; 12 | import java.util.Optional; 13 | 14 | import static org.junit.Assert.assertEquals; 15 | 16 | public class ExchangeBalanceCacheTest extends BaseTestCase { 17 | private Exchange exchangeA; 18 | private Exchange exchangeB; 19 | 20 | private ExchangeBalanceCache cache; 21 | 22 | @Before 23 | public void setUp() throws IOException { 24 | exchangeA = new ExchangeBuilder("CoinDynasty", CurrencyPair.BTC_USD) 25 | .build(); 26 | 27 | exchangeB = new ExchangeBuilder("CoinSnake", CurrencyPair.BTC_USD) 28 | .build(); 29 | 30 | cache = new ExchangeBalanceCache(); 31 | } 32 | 33 | @Test 34 | public void testGetCachedBalance() { 35 | BigDecimal value = new BigDecimal("123.45"); 36 | 37 | cache.setCachedBalance(exchangeA, value); 38 | 39 | assertEquals(Optional.of(value), cache.getCachedBalance(exchangeA)); 40 | } 41 | 42 | @Test 43 | public void testGetCachedBalances() { 44 | BigDecimal valueA = new BigDecimal("123.45"); 45 | BigDecimal valueB = new BigDecimal("987.65"); 46 | 47 | cache.setCachedBalance(exchangeA, valueA); 48 | cache.setCachedBalance(exchangeB, valueB); 49 | 50 | assertEquals(Optional.of(valueA), cache.getCachedBalance(exchangeA)); 51 | assertEquals(Optional.of(valueB), cache.getCachedBalance(exchangeB)); 52 | } 53 | 54 | @Test 55 | public void testCacheExpiration() { 56 | BigDecimal value = new BigDecimal("123.45"); 57 | 58 | cache.setCachedBalance(exchangeA, value, System.currentTimeMillis() - (ExchangeBalanceCache.CACHE_TIMEOUT + 1)); 59 | 60 | assertEquals(Optional.empty(), cache.getCachedBalance(exchangeA)); 61 | } 62 | 63 | @Test 64 | public void testCacheInvalidation() { 65 | BigDecimal valueA = new BigDecimal("123.45"); 66 | BigDecimal valueB = new BigDecimal("987.65"); 67 | 68 | cache.setCachedBalance(exchangeA, valueA); 69 | cache.setCachedBalance(exchangeB, valueB); 70 | 71 | assertEquals(Optional.of(valueA), cache.getCachedBalance(exchangeA)); 72 | assertEquals(Optional.of(valueB), cache.getCachedBalance(exchangeB)); 73 | 74 | cache.invalidate(exchangeA); 75 | 76 | assertEquals(Optional.empty(), cache.getCachedBalance(exchangeA)); 77 | assertEquals(Optional.of(valueB), cache.getCachedBalance(exchangeB)); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/cache/ExchangeFeeCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.cache; 2 | 3 | import com.agonyforge.arbitrader.BaseTestCase; 4 | import com.agonyforge.arbitrader.service.model.ExchangeFee; 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | import org.knowm.xchange.Exchange; 8 | import org.knowm.xchange.ExchangeSpecification; 9 | import org.knowm.xchange.currency.CurrencyPair; 10 | import org.mockito.Mock; 11 | 12 | import java.math.BigDecimal; 13 | import java.util.Optional; 14 | 15 | import static org.junit.Assert.assertEquals; 16 | import static org.junit.Assert.assertFalse; 17 | import static org.junit.Assert.assertTrue; 18 | import static org.mockito.Mockito.mock; 19 | import static org.mockito.Mockito.when; 20 | 21 | public class ExchangeFeeCacheTest extends BaseTestCase { 22 | @Mock 23 | private Exchange exchange; 24 | 25 | @Mock 26 | private ExchangeSpecification exchangeSpecification; 27 | 28 | private CurrencyPair currencyPair; 29 | private ExchangeFeeCache exchangeFeeCache; 30 | 31 | @Before 32 | public void setUp() { 33 | when(exchange.getExchangeSpecification()).thenReturn(exchangeSpecification); 34 | when(exchangeSpecification.getExchangeName()).thenReturn("WhatCoin"); 35 | 36 | currencyPair = new CurrencyPair("COIN", "USD"); 37 | 38 | exchangeFeeCache = new ExchangeFeeCache(); 39 | } 40 | 41 | @Test 42 | public void testSetAndGet() { 43 | exchangeFeeCache.setCachedFee(exchange, currencyPair, new ExchangeFee(new BigDecimal("0.0025"), null)); 44 | exchangeFeeCache.setCachedFee(exchange, CurrencyPair.BTC_USD, new ExchangeFee(new BigDecimal("0.0010"), new BigDecimal("0.0002"))); 45 | exchangeFeeCache.setCachedFee(exchange, CurrencyPair.ETH_USD, new ExchangeFee(new BigDecimal("0.0030"), new BigDecimal("0.0001"))); 46 | exchangeFeeCache.setCachedFee(exchange, CurrencyPair.BCC_USD, new ExchangeFee(new BigDecimal("0.0030"), null)); 47 | 48 | assertTrue(exchangeFeeCache.getCachedFee(exchange, currencyPair).isPresent()); 49 | assertEquals(new BigDecimal("0.0025"), exchangeFeeCache.getCachedFee(exchange, currencyPair).get().getTradeFee()); 50 | assertFalse(exchangeFeeCache.getCachedFee(exchange, currencyPair).get().getMarginFee().isPresent()); 51 | } 52 | 53 | @Test 54 | public void testGetUnknownPair() { 55 | CurrencyPair altPair = new CurrencyPair("FAKE", "USD"); 56 | 57 | assertEquals(Optional.empty(), exchangeFeeCache.getCachedFee(exchange, altPair)); 58 | } 59 | 60 | @Test 61 | public void testGetUnknownExchange() { 62 | Exchange altExchange = mock(Exchange.class); 63 | ExchangeSpecification altSpec = mock(ExchangeSpecification.class); 64 | 65 | when(altExchange.getExchangeSpecification()).thenReturn(altSpec); 66 | when(altSpec.getExchangeName()).thenReturn("AltEx"); 67 | 68 | assertEquals(Optional.empty(), exchangeFeeCache.getCachedFee(altExchange, currencyPair)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/cache/OrderVolumeCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.cache; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.agonyforge.arbitrader.BaseTestCase; 5 | import org.junit.Before; 6 | import org.junit.Test; 7 | import org.knowm.xchange.Exchange; 8 | import org.knowm.xchange.currency.CurrencyPair; 9 | 10 | import java.io.IOException; 11 | import java.math.BigDecimal; 12 | import java.util.Optional; 13 | 14 | import static com.agonyforge.arbitrader.service.cache.OrderVolumeCache.CACHE_SIZE; 15 | import static org.junit.Assert.assertEquals; 16 | 17 | public class OrderVolumeCacheTest extends BaseTestCase { 18 | private Exchange exchangeA; 19 | private Exchange exchangeB; 20 | 21 | private OrderVolumeCache cache; 22 | 23 | @Before 24 | public void setUp() throws IOException { 25 | exchangeA = new ExchangeBuilder("CoinDynasty", CurrencyPair.BTC_USD).build(); 26 | exchangeB = new ExchangeBuilder("CoinSnake", CurrencyPair.BTC_USD).build(); 27 | 28 | cache = new OrderVolumeCache(); 29 | } 30 | 31 | @Test 32 | public void testGetCachedVolume() { 33 | BigDecimal valueA = new BigDecimal("123.45"); 34 | BigDecimal valueB = new BigDecimal("987.65"); 35 | 36 | cache.setCachedVolume(exchangeA, "1", valueA); 37 | cache.setCachedVolume(exchangeB, "2", valueB); 38 | 39 | assertEquals(Optional.of(valueA), cache.getCachedVolume(exchangeA, "1")); 40 | assertEquals(Optional.empty(), cache.getCachedVolume(exchangeA, "2")); 41 | assertEquals(Optional.of(valueB), cache.getCachedVolume(exchangeB, "2")); 42 | assertEquals(Optional.empty(), cache.getCachedVolume(exchangeB, "1")); 43 | } 44 | 45 | @Test 46 | public void testGetCachedVolumeSameIds() { 47 | BigDecimal valueA = new BigDecimal("123.45"); 48 | BigDecimal valueB = new BigDecimal("987.65"); 49 | 50 | cache.setCachedVolume(exchangeA, "1", valueA); 51 | cache.setCachedVolume(exchangeB, "1", valueB); 52 | 53 | assertEquals(Optional.of(valueA), cache.getCachedVolume(exchangeA, "1")); 54 | assertEquals(Optional.empty(), cache.getCachedVolume(exchangeA, "2")); 55 | assertEquals(Optional.of(valueB), cache.getCachedVolume(exchangeB, "1")); 56 | assertEquals(Optional.empty(), cache.getCachedVolume(exchangeB, "2")); 57 | } 58 | 59 | @Test 60 | public void testCacheSize() { 61 | for (int i = 0; i < CACHE_SIZE; i++) { 62 | BigDecimal value = new BigDecimal(i + ".00"); 63 | String orderId = "Order" + i; 64 | 65 | cache.setCachedVolume(exchangeA, orderId, value); 66 | } 67 | 68 | for (int i = 0; i < CACHE_SIZE; i++) { 69 | assertEquals( 70 | Optional.of(new BigDecimal(i + ".00")), 71 | cache.getCachedVolume(exchangeA, "Order" + i)); 72 | } 73 | 74 | BigDecimal lastValue = new BigDecimal(CACHE_SIZE + ".00"); 75 | String lastOrderId = "Order" + CACHE_SIZE; 76 | 77 | cache.setCachedVolume(exchangeA, lastOrderId, lastValue); 78 | 79 | assertEquals(Optional.of(lastValue), cache.getCachedVolume(exchangeA, lastOrderId)); 80 | assertEquals(Optional.empty(), cache.getCachedVolume(exchangeA, "Order0")); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/model/ActivePositionTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import com.agonyforge.arbitrader.config.JsonConfiguration; 6 | import org.junit.Before; 7 | import org.junit.Test; 8 | import org.knowm.xchange.Exchange; 9 | import org.knowm.xchange.currency.Currency; 10 | import org.knowm.xchange.currency.CurrencyPair; 11 | import org.mockito.MockitoAnnotations; 12 | 13 | import java.io.IOException; 14 | import java.math.BigDecimal; 15 | import java.math.RoundingMode; 16 | import java.nio.charset.StandardCharsets; 17 | import java.time.OffsetDateTime; 18 | import java.util.UUID; 19 | 20 | import static com.agonyforge.arbitrader.DecimalConstants.USD_SCALE; 21 | import static org.junit.Assert.assertEquals; 22 | 23 | public class ActivePositionTest { 24 | private ObjectMapper objectMapper; 25 | private CurrencyPair currencyPair = CurrencyPair.BTC_USD; 26 | private BigDecimal exitTarget = new BigDecimal("0.003"); 27 | private Exchange longExchange = null; 28 | private Exchange shortExchange = null; 29 | private BigDecimal longVolume = new BigDecimal("1000.00"); 30 | private BigDecimal shortVolume = new BigDecimal("1000.00"); 31 | private BigDecimal longLimitPrice = new BigDecimal("5000.00"); 32 | private BigDecimal shortLimitPrice = new BigDecimal("5000.00"); 33 | 34 | @Before 35 | public void setUp() throws IOException { 36 | MockitoAnnotations.initMocks(this); 37 | 38 | objectMapper = new JsonConfiguration().objectMapper(); 39 | 40 | longExchange = new ExchangeBuilder("Long", CurrencyPair.BTC_USD) 41 | .withTradeService() 42 | .withOrderBook(100, 100) 43 | .withBalance(Currency.USD, new BigDecimal("100.00").setScale(USD_SCALE, RoundingMode.HALF_EVEN)) 44 | .build(); 45 | shortExchange = new ExchangeBuilder("Short", CurrencyPair.BTC_USD) 46 | .withBalance(Currency.USD, new BigDecimal("500.00").setScale(USD_SCALE, RoundingMode.HALF_EVEN)) 47 | .build(); 48 | } 49 | 50 | @Test 51 | public void testSerialization() throws IOException{ 52 | ActivePosition activePosition = new ActivePosition(); 53 | 54 | activePosition.setEntryTime(OffsetDateTime.now()); 55 | activePosition.setEntryBalance(new BigDecimal("1337.00")); 56 | activePosition.setCurrencyPair(currencyPair); 57 | activePosition.setExitTarget(exitTarget); 58 | activePosition.getLongTrade().setOrderId(UUID.randomUUID().toString()); 59 | activePosition.getLongTrade().setExchange(longExchange); 60 | activePosition.getLongTrade().setVolume(longVolume); 61 | activePosition.getLongTrade().setEntry(longLimitPrice); 62 | activePosition.getShortTrade().setOrderId(UUID.randomUUID().toString()); 63 | activePosition.getShortTrade().setExchange(shortExchange); 64 | activePosition.getShortTrade().setVolume(shortVolume); 65 | activePosition.getShortTrade().setEntry(shortLimitPrice); 66 | 67 | String json = objectMapper.writeValueAsString(activePosition); 68 | 69 | ActivePosition deserialized = objectMapper.readValue(json.getBytes(StandardCharsets.UTF_8), ActivePosition.class); 70 | 71 | assertEquals(activePosition, deserialized); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/model/TradeCombinationTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.model; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | import org.knowm.xchange.Exchange; 6 | import org.knowm.xchange.currency.CurrencyPair; 7 | import org.mockito.Mock; 8 | import org.mockito.MockitoAnnotations; 9 | 10 | import static org.junit.Assert.assertEquals; 11 | 12 | public class TradeCombinationTest { 13 | @Mock 14 | private Exchange longExchange; 15 | 16 | @Mock 17 | private Exchange shortExchange; 18 | 19 | private CurrencyPair currencyPair = CurrencyPair.BTC_USD; 20 | 21 | @Before 22 | public void setUp() { 23 | MockitoAnnotations.initMocks(this); 24 | } 25 | 26 | @Test 27 | public void testCreateAndGet() { 28 | TradeCombination tradeCombination = new TradeCombination(longExchange, shortExchange, currencyPair); 29 | 30 | assertEquals(longExchange, tradeCombination.getLongExchange()); 31 | assertEquals(shortExchange, tradeCombination.getShortExchange()); 32 | assertEquals(currencyPair, tradeCombination.getCurrencyPair()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/ticker/ParallelTickerStrategyTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 5 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 6 | import com.agonyforge.arbitrader.service.cache.ExchangeFeeCache; 7 | import com.agonyforge.arbitrader.service.ExchangeService; 8 | import com.agonyforge.arbitrader.service.TickerService; 9 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 10 | import com.agonyforge.arbitrader.service.model.TickerEvent; 11 | import org.junit.Before; 12 | import org.junit.Test; 13 | import org.knowm.xchange.Exchange; 14 | import org.knowm.xchange.currency.CurrencyPair; 15 | import org.knowm.xchange.dto.marketdata.Ticker; 16 | import org.knowm.xchange.exceptions.ExchangeException; 17 | import org.mockito.Mock; 18 | import org.mockito.MockitoAnnotations; 19 | 20 | import java.io.IOException; 21 | import java.lang.reflect.UndeclaredThrowableException; 22 | import java.util.Collections; 23 | import java.util.List; 24 | 25 | import static org.junit.Assert.*; 26 | import static org.mockito.ArgumentMatchers.any; 27 | import static org.mockito.ArgumentMatchers.eq; 28 | import static org.mockito.Mockito.never; 29 | import static org.mockito.Mockito.verify; 30 | 31 | public class ParallelTickerStrategyTest { 32 | private List currencyPairs = Collections.singletonList(CurrencyPair.BTC_USD); 33 | 34 | private ErrorCollectorService errorCollectorService; 35 | private TickerStrategy tickerStrategy; 36 | 37 | @Mock 38 | private TickerService tickerService; 39 | 40 | @Mock 41 | private TickerStrategyProvider tickerStrategyProvider; 42 | 43 | @Mock 44 | private TickerEventPublisher tickerEventPublisher; 45 | 46 | @Before 47 | public void setUp() { 48 | MockitoAnnotations.initMocks(this); 49 | 50 | NotificationConfiguration notificationConfiguration = new NotificationConfiguration(); 51 | ExchangeService exchangeService = new ExchangeService(new ExchangeFeeCache(), tickerStrategyProvider); 52 | 53 | errorCollectorService = new ErrorCollectorService(); 54 | 55 | tickerStrategy = new ParallelTickerStrategy(notificationConfiguration, errorCollectorService, exchangeService, tickerEventPublisher); 56 | } 57 | 58 | @Test 59 | public void testGetTickers() throws IOException { 60 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 61 | .withTickerStrategy(tickerStrategy) 62 | .withTickers( 63 | true, 64 | Collections.singletonList(CurrencyPair.BTC_USD)) 65 | .build(); 66 | 67 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 68 | 69 | assertTrue(errorCollectorService.isEmpty()); 70 | 71 | verify(tickerService).putTicker(eq(exchange), any(Ticker.class)); 72 | verify(tickerEventPublisher).publishTicker(any(TickerEvent.class)); 73 | } 74 | 75 | @Test 76 | public void testGetTickersExchangeException() throws IOException { 77 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 78 | .withTickerStrategy(tickerStrategy) 79 | .withTickers(new ExchangeException("Boom!")) 80 | .build(); 81 | 82 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 83 | 84 | assertFalse(errorCollectorService.isEmpty()); 85 | 86 | verify(tickerService, never()).putTicker(eq(exchange), any(Ticker.class)); 87 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 88 | } 89 | 90 | @Test 91 | public void testGetTickersIOException() throws IOException { 92 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 93 | .withTickerStrategy(tickerStrategy) 94 | .withTickers(new IOException("Boom!")) 95 | .build(); 96 | 97 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 98 | 99 | assertFalse(errorCollectorService.isEmpty()); 100 | 101 | verify(tickerService, never()).putTicker(eq(exchange), any(Ticker.class)); 102 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 103 | } 104 | 105 | @Test 106 | public void testGetTickersUndeclaredThrowableException() throws IOException { 107 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 108 | .withTickerStrategy(tickerStrategy) 109 | .withTickers(new UndeclaredThrowableException(new IOException("Boom!"))) 110 | .build(); 111 | 112 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 113 | 114 | assertFalse(errorCollectorService.isEmpty()); 115 | 116 | verify(tickerService, never()).putTicker(eq(exchange), any(Ticker.class)); 117 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/ticker/SingleCallTickerStrategyTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.agonyforge.arbitrader.config.NotificationConfiguration; 5 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 6 | import com.agonyforge.arbitrader.service.ExchangeService; 7 | import com.agonyforge.arbitrader.service.TickerService; 8 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 9 | import com.agonyforge.arbitrader.service.model.TickerEvent; 10 | import org.junit.Before; 11 | import org.junit.Test; 12 | import org.knowm.xchange.Exchange; 13 | import org.knowm.xchange.currency.CurrencyPair; 14 | import org.knowm.xchange.dto.marketdata.Ticker; 15 | import org.knowm.xchange.exceptions.ExchangeException; 16 | import org.mockito.Mock; 17 | import org.mockito.MockitoAnnotations; 18 | 19 | import java.io.IOException; 20 | import java.lang.reflect.UndeclaredThrowableException; 21 | import java.util.Collections; 22 | import java.util.List; 23 | 24 | import static org.junit.Assert.*; 25 | import static org.mockito.ArgumentMatchers.any; 26 | import static org.mockito.ArgumentMatchers.eq; 27 | import static org.mockito.Mockito.never; 28 | import static org.mockito.Mockito.verify; 29 | 30 | public class SingleCallTickerStrategyTest { 31 | private List currencyPairs = Collections.singletonList(CurrencyPair.BTC_USD); 32 | 33 | private ErrorCollectorService errorCollectorService; 34 | private TickerStrategy tickerStrategy; 35 | 36 | @Mock 37 | private ExchangeService exchangeService; 38 | 39 | @Mock 40 | private TickerService tickerService; 41 | 42 | @Mock 43 | private TickerEventPublisher tickerEventPublisher; 44 | 45 | @Before 46 | public void setUp() { 47 | MockitoAnnotations.initMocks(this); 48 | 49 | NotificationConfiguration notificationConfiguration = new NotificationConfiguration(); 50 | 51 | errorCollectorService = new ErrorCollectorService(); 52 | 53 | tickerStrategy = new SingleCallTickerStrategy(notificationConfiguration, errorCollectorService, exchangeService, tickerEventPublisher); 54 | } 55 | 56 | @Test 57 | public void testGetTickers() throws IOException { 58 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 59 | .withTickerStrategy(tickerStrategy) 60 | .withTickers( 61 | true, 62 | Collections.singletonList(CurrencyPair.BTC_USD)) 63 | .build(); 64 | 65 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 66 | 67 | assertTrue(errorCollectorService.isEmpty()); 68 | 69 | verify(tickerService).putTicker(eq(exchange), any(Ticker.class)); 70 | verify(tickerEventPublisher).publishTicker(any(TickerEvent.class)); 71 | } 72 | 73 | @Test 74 | public void testGetTickersExchangeException() throws IOException { 75 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 76 | .withTickerStrategy(tickerStrategy) 77 | .withTickers(new ExchangeException("Boom!")) 78 | .build(); 79 | 80 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 81 | 82 | assertFalse(errorCollectorService.isEmpty()); 83 | 84 | verify(tickerService, never()).putTicker(eq(exchange), any(Ticker.class)); 85 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 86 | } 87 | 88 | @Test 89 | public void testGetTickersIOException() throws IOException { 90 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 91 | .withTickerStrategy(tickerStrategy) 92 | .withTickers(new IOException("Boom!")) 93 | .build(); 94 | 95 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 96 | 97 | assertFalse(errorCollectorService.isEmpty()); 98 | 99 | verify(tickerService, never()).putTicker(eq(exchange), any(Ticker.class)); 100 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 101 | } 102 | 103 | @Test 104 | public void testGetTickersUndeclaredThrowableException() throws IOException { 105 | Exchange exchange = new ExchangeBuilder("CrazyCoinz", CurrencyPair.BTC_USD) 106 | .withTickerStrategy(tickerStrategy) 107 | .withTickers(new UndeclaredThrowableException(new IOException("Boom!"))) 108 | .build(); 109 | 110 | tickerStrategy.getTickers(exchange, currencyPairs, tickerService); 111 | 112 | assertFalse(errorCollectorService.isEmpty()); 113 | 114 | verify(tickerService, never()).putTicker(eq(exchange), any(Ticker.class)); 115 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/test/java/com/agonyforge/arbitrader/service/ticker/StreamingTickerStrategyTest.java: -------------------------------------------------------------------------------- 1 | package com.agonyforge.arbitrader.service.ticker; 2 | 3 | import com.agonyforge.arbitrader.ExchangeBuilder; 4 | import com.agonyforge.arbitrader.service.ErrorCollectorService; 5 | import com.agonyforge.arbitrader.service.ExchangeService; 6 | import com.agonyforge.arbitrader.service.TickerService; 7 | import com.agonyforge.arbitrader.service.event.TickerEventPublisher; 8 | import com.agonyforge.arbitrader.service.model.TickerEvent; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.knowm.xchange.Exchange; 12 | import org.knowm.xchange.currency.CurrencyPair; 13 | import org.knowm.xchange.dto.marketdata.Ticker; 14 | import org.mockito.Mock; 15 | import org.mockito.MockitoAnnotations; 16 | 17 | import java.util.Collections; 18 | 19 | import static org.junit.Assert.assertTrue; 20 | import static org.mockito.ArgumentMatchers.any; 21 | import static org.mockito.ArgumentMatchers.eq; 22 | import static org.mockito.Mockito.never; 23 | import static org.mockito.Mockito.verify; 24 | 25 | public class StreamingTickerStrategyTest { 26 | @Mock 27 | private ErrorCollectorService errorCollectorService; 28 | 29 | @Mock 30 | private ExchangeService exchangeService; 31 | 32 | @Mock 33 | private TickerService tickerService; 34 | 35 | @Mock 36 | private TickerEventPublisher tickerEventPublisher; 37 | 38 | private StreamingTickerStrategy streamingTickerStrategy; 39 | 40 | @Before 41 | public void setUp() { 42 | MockitoAnnotations.initMocks(this); 43 | 44 | streamingTickerStrategy = new StreamingTickerStrategy(errorCollectorService, exchangeService, tickerEventPublisher); 45 | } 46 | 47 | @Test 48 | public void testInvalidExchange() throws Exception { 49 | Exchange nonStreamingExchange = new ExchangeBuilder("CrazyCoinz",CurrencyPair.BTC_USD).build(); 50 | 51 | streamingTickerStrategy.getTickers( 52 | nonStreamingExchange, 53 | Collections.singletonList(CurrencyPair.BTC_USD), 54 | tickerService); 55 | 56 | verify(tickerService, never()).putTicker(eq(nonStreamingExchange), any(Ticker.class)); 57 | verify(tickerEventPublisher, never()).publishTicker(any(TickerEvent.class)); 58 | } 59 | } 60 | --------------------------------------------------------------------------------