├── .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 | 
3 | [](https://discord.gg/ZYmG3Nw)
4 | [](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 extends Exchange> 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 |
--------------------------------------------------------------------------------