Skip to content

Implemented Cycle Detection #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
<img src="illustration.jpeg" width="250px" height="250px" alt="Triangular illustration">
</p>

# Triangular Arbitrage by OctoBot [1.1.1](https://github.com/Drakkar-Software/Triangular-Arbitrage/blob/master/CHANGELOG.md)
# Arbitrage Opportunity Detection by OctoBot [1.1.1](https://github.com/Drakkar-Software/Triangular-Arbitrage/blob/master/CHANGELOG.md)
[![PyPI](https://img.shields.io/pypi/v/OctoBot-Triangular-Arbitrage.svg)](https://pypi.python.org/pypi/OctoBot-Triangular-Arbitrage/)
[![Dockerhub](https://img.shields.io/docker/pulls/drakkarsoftware/octobot-triangular-arbitrage.svg?logo=docker)](https://hub.docker.com/r/drakkarsoftware/octobot-triangular-arbitrage)

This Python-based project utilizes the [ccxt library](https://github.com/ccxt/ccxt) and [OctoBot library](https://github.com/Drakkar-Software/OctoBot) to detect potential triangular arbitrage opportunities in cryptocurrency markets.
This Python-based project utilizes the [ccxt library](https://github.com/ccxt/ccxt) and the [OctoBot library](https://github.com/Drakkar-Software/OctoBot) to detect potential arbitrage opportunities across multiple assets in cryptocurrency markets. It identifies profitable cycles where you can trade through a series of assets and return to the original asset with a potential gain, making it applicable for arbitrage strategies beyond just triangular cycles.

## Description

Triangular arbitrage is a process where you trade from one currency to another, and then to another, and finally back to the original currency. The goal is to exploit differences in prices between the three currencies to make a profit. For example, you could start with USD, buy BTC, then use the BTC to buy ETH, and finally sell the ETH for USD. If the prices are right, you could end up with more USD than you started with. This project provides a method to identify the best triangular arbitrage opportunity given a list of last prices for different cryptocurrency pairs. It's a simple and effective tool for anyone interested in cryptocurrency trading and arbitrage strategies.
Arbitrage trading is a process where you trade from one asset or currency to another, and then continue trading through a series of assets until you eventually return to the original asset or currency. The goal is to exploit price differences between multiple assets to generate a profit. For example, you could start with USD, buy BTC, use the BTC to buy ETH, trade the ETH for XRP, and finally sell the XRP back to USD. If the prices are favorable throughout the cycle, you could end up with more USD than you started with. This project provides a method to identify the best arbitrage opportunities in a multi-asset cycle, given a list of last prices for different cryptocurrency pairs. It's a versatile and effective tool for anyone interested in cryptocurrency trading and arbitrage strategies across various currencies and assets.

## Getting Started

Expand All @@ -33,10 +33,14 @@ python3 main.py
Example output on Binance:
```
-------------------------------------------
New 1.0354% binance opportunity:
1. sell WIN/BNB
2. sell BNB/BRL
3. buy WIN/BRL
New 2.33873% binanceus opportunity:
# 1. buy DOGE to BTC at 552486.18785
# 2. sell DOGE to USDT at 0.12232
# 3. buy ETH to USDT at 0.00038
# 4. buy ADA to ETH at 7570.02271
# 5. sell ADA to USDC at 0.35000
# 6. buy SOL to USDC at 0.00662
# 7. sell SOL to BTC at 0.00226
-------------------------------------------
```

Expand Down
26 changes: 22 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

# start arbitrage detection
print("Scanning...")
exchange_name = "binance"
exchange_name = "binance" # allow pickable exchange_id from https://github.com/ccxt/ccxt/wiki/manual#exchanges

best_opportunities, best_profit = asyncio.run(detector.run_detection(exchange_name))


Expand All @@ -29,9 +30,26 @@ def get_order_side(opportunity: detector.ShortTicker):
if best_opportunities is not None:
# Display arbitrage detection result
print("-------------------------------------------")
print(f"New {round(best_profit - 1, 5) * 100}% {exchange_name} opportunity:")
for i in range(3):
print(f"{i + 1}. {get_order_side(best_opportunities[i])} {str(best_opportunities[i].symbol)}")
total_profit_percentage = round((best_profit - 1) * 100, 5)
print(f"New {total_profit_percentage}% {exchange_name} opportunity:")
for i, opportunity in enumerate(best_opportunities):
# Get the base and quote currencies
base_currency = opportunity.symbol.base
quote_currency = opportunity.symbol.quote

# Format the output as below (real live example):
# -------------------------------------------
# New 2.33873% binanceus opportunity:
# 1. buy DOGE to BTC at 552486.18785
# 2. sell DOGE to USDT at 0.12232
# 3. buy ETH to USDT at 0.00038
# 4. buy ADA to ETH at 7570.02271
# 5. sell ADA to USDC at 0.35000
# 6. buy SOL to USDC at 0.00662
# 7. sell SOL to BTC at 0.00226
# -------------------------------------------
order_side = get_order_side(opportunity)
print(f"{i+1}. {order_side} {base_currency} to {quote_currency} at {opportunity.last_price:.5f}")
Comment on lines +34 to +52
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great example!

print("-------------------------------------------")
else:
print("No opportunity detected")
Expand Down
53 changes: 47 additions & 6 deletions tests/test_detector.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest
import octobot_commons.symbols as symbols
from triangular_arbitrage.detector import ShortTicker, get_best_opportunity
from triangular_arbitrage.detector import ShortTicker, get_best_triangular_opportunity, get_best_opportunity


@pytest.fixture
Expand All @@ -14,16 +14,28 @@ def sample_tickers():
]


def test_get_best_triangular_opportunity_handles_empty_tickers():
best_opportunity, best_profit = get_best_triangular_opportunity([])
assert best_profit == 1
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary since the we need to multiply all the stock prices by the number.

The reason why the old test (with best_profit = 0) worked for the old code is, if no opportunity or empty, the multiplication never occurred as it was hardcoded to only accept minimum a trio of tickers.

However, for our new code, even if there is 0, 1, or 2 tickers, the multiplication occurs, and the base line for profit will be 1 since we are multiplying 1 by the exchange rates to maintain identity.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, you're right it's better without best_profit = 0

assert best_opportunity is None


def test_get_best_triangular_opportunity_handles_no_cycle_opportunity(sample_tickers):
sample_tickers.append(ShortTicker(symbol=symbols.Symbol('DOT/USDT'), last_price=0.05))
best_opportunity, best_profit = get_best_triangular_opportunity(sample_tickers)
assert best_profit == 1
assert best_opportunity is None

def test_get_best_opportunity_handles_empty_tickers():
best_opportunity, best_profit = get_best_opportunity([])
assert best_profit == 0
assert best_profit == 1
assert best_opportunity is None


def test_get_best_opportunity_handles_no_triplet_opportunity(sample_tickers):
sample_tickers.append(ShortTicker(symbol=symbols.Symbol('DOT/USDT'), last_price=0.05))
best_opportunity, best_profit = get_best_opportunity(sample_tickers)
assert best_profit == 0
assert best_profit == 1
assert best_opportunity is None


Expand All @@ -33,7 +45,7 @@ def test_get_best_opportunity_returns_correct_triplet_with_correct_tickers():
ShortTicker(symbol=symbols.Symbol('ETH/BTC'), last_price=0.3),
ShortTicker(symbol=symbols.Symbol('ETH/USDT'), last_price=2000),
]
best_opportunity, best_profit = get_best_opportunity(tickers)
best_opportunity, best_profit = get_best_triangular_opportunity(tickers)
assert len(best_opportunity) == 3
assert best_profit == 4.5
assert all(isinstance(ticker, ShortTicker) for ticker in best_opportunity)
Expand All @@ -51,7 +63,36 @@ def test_get_best_opportunity_returns_correct_triplet_with_multiple_tickers():
ShortTicker(symbol=symbols.Symbol('ETH/TUSD'), last_price=1950),
ShortTicker(symbol=symbols.Symbol('BTC/TUSD'), last_price=32500),
]
best_opportunity, best_profit = get_best_opportunity(tickers)
best_opportunity, best_profit = get_best_triangular_opportunity(tickers)
assert len(best_opportunity) == 3
assert best_profit == 5.526315789473684
assert round(best_profit, 3) == 5.526 # 5.526315789473684
assert all(isinstance(ticker, ShortTicker) for ticker in best_opportunity)

def test_get_best_opportunity_returns_correct_cycle_with_correct_tickers():
tickers = [
ShortTicker(symbol=symbols.Symbol('BTC/USDT'), last_price=30000),
ShortTicker(symbol=symbols.Symbol('ETH/BTC'), last_price=0.3),
ShortTicker(symbol=symbols.Symbol('ETH/USDT'), last_price=2000),
]
best_opportunity, best_profit = get_best_opportunity(tickers)
assert len(best_opportunity) >= 3 # Handling cycles with more than 3 tickers
assert best_profit == 4.5
assert all(isinstance(ticker, ShortTicker) for ticker in best_opportunity)


def test_get_best_opportunity_returns_correct_cycle_with_multiple_tickers():
tickers = [
ShortTicker(symbol=symbols.Symbol('BTC/USDT'), last_price=30000),
ShortTicker(symbol=symbols.Symbol('ETH/BTC'), last_price=0.3),
ShortTicker(symbol=symbols.Symbol('ETH/USDT'), last_price=2000),
ShortTicker(symbol=symbols.Symbol('ETH/USDC'), last_price=1900),
ShortTicker(symbol=symbols.Symbol('BTC/USDC'), last_price=35000),
ShortTicker(symbol=symbols.Symbol('USDC/USDT'), last_price=1.1),
ShortTicker(symbol=symbols.Symbol('USDC/TUSD'), last_price=0.95),
ShortTicker(symbol=symbols.Symbol('ETH/TUSD'), last_price=1950),
ShortTicker(symbol=symbols.Symbol('BTC/TUSD'), last_price=32500),
]
best_opportunity, best_profit = get_best_opportunity(tickers)
assert len(best_opportunity) >= 3 # Handling cycles with more than 3 tickers
assert round(best_profit, 3) == 5.775
assert all(isinstance(ticker, ShortTicker) for ticker in best_opportunity)
49 changes: 28 additions & 21 deletions triangular_arbitrage/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ def get_last_prices(exchange_time, tickers, ignored_symbols, whitelisted_symbols
]


def get_best_opportunity(tickers: List[ShortTicker]) -> Tuple[List[ShortTicker], float]:
def get_best_triangular_opportunity(tickers: List[ShortTicker]) -> Tuple[List[ShortTicker], float]:
# Build a directed graph of currencies
return get_best_opportunity(tickers, 3)
Comment on lines +48 to +50
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's great!


def get_best_opportunity(tickers: List[ShortTicker], max_cycle: int = 10) -> Tuple[List[ShortTicker], float]:
# Build a directed graph of currencies
graph = nx.DiGraph()

Expand All @@ -56,34 +60,37 @@ def get_best_opportunity(tickers: List[ShortTicker]) -> Tuple[List[ShortTicker],
ticker=ShortTicker(symbols.Symbol(f"{ticker.symbol.quote}/{ticker.symbol.base}"),
1 / ticker.last_price, reversed=True))

best_profit = 0
best_triplet = None
best_profit = 1
Copy link
Contributor Author

@ruidazeng ruidazeng Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

base case must be = 1, since 100%-100% means no profit, and 1 is the multiplicative identity (we are multiplying the exchange rates, and not adding, the original best_profit = 0 does not make sense)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

best_cycle = None

# Find all cycles in the graph with a length <= max_cycle
for cycle in nx.simple_cycles(graph):
if len(cycle) != 3:
continue
if len(cycle) > max_cycle:
continue # Skip cycles longer than max_cycle

a, b, c = cycle
a_to_b = graph[a][b]['ticker']
b_to_c = graph[b][c]['ticker']
c_to_a = graph[c][a]['ticker']
profit = 1
tickers_in_cycle = []

profit = a_to_b.last_price * b_to_c.last_price * c_to_a.last_price
# Calculate the profits along the cycle
for i, base in enumerate(cycle):
quote = cycle[(i + 1) % len(cycle)] # Wrap around to complete the cycle
ticker = graph[base][quote]['ticker']
tickers_in_cycle.append(ticker)
profit *= ticker.last_price

if profit > best_profit:
best_profit = profit
best_triplet = [a_to_b, b_to_c, c_to_a]

if best_triplet is not None:
# restore original symbols for reversed pairs
best_triplet = [
ShortTicker(symbols.Symbol(f"{triplet.symbol.quote}/{triplet.symbol.base}"), triplet.last_price,
reversed=True)
if triplet.reversed else triplet
for triplet in best_triplet
best_cycle = tickers_in_cycle

if best_cycle is not None:
best_cycle = [
ShortTicker(symbols.Symbol(f"{ticker.symbol.quote}/{ticker.symbol.base}"), ticker.last_price, reversed=True)
if ticker.reversed else ticker
for ticker in best_cycle
]

return best_triplet, best_profit
return best_cycle, best_profit



async def get_exchange_data(exchange_name):
Expand All @@ -103,5 +110,5 @@ async def get_exchange_last_prices(exchange_name, ignored_symbols, whitelisted_s

async def run_detection(exchange_name, ignored_symbols=None, whitelisted_symbols=None):
last_prices = await get_exchange_last_prices(exchange_name, ignored_symbols or [], whitelisted_symbols)
best_opportunity, best_profit = get_best_opportunity(last_prices)
best_opportunity, best_profit = get_best_opportunity(last_prices) # default is best opportunity for all cycles
return best_opportunity, best_profit
Loading