Compare commits
180 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
d08e175ea7 | ||
|
bf85e39c2c | ||
|
fbe445f712 | ||
|
78b5b4d0f7 | ||
|
4f7d9042d6 | ||
|
f43fcb0206 | ||
|
354ef407f3 | ||
|
a3ce3682ad | ||
|
f4431f26c0 | ||
|
545bfe28de | ||
|
8bd9eb048d | ||
|
093269b173 | ||
|
5cd6f8d37c | ||
|
a2cddead14 | ||
|
8e6e58435d | ||
|
df45866b5f | ||
|
793fd40b4c | ||
|
888843d853 | ||
|
9be8c2e6ff | ||
|
1948b0f032 | ||
|
fdc93e9cbe | ||
|
d8d0e07a0b | ||
|
415944bde5 | ||
|
6d873f3c14 | ||
|
f31c778d66 | ||
|
5e39f2767f | ||
|
4df9e38569 | ||
|
0df1511a59 | ||
|
47ddc44721 | ||
|
f4d7786e03 | ||
|
551ca054c6 | ||
|
a73e59e0e3 | ||
|
20b838f635 | ||
|
2aa9bcdb95 | ||
|
3adceef1e9 | ||
|
4592d755aa | ||
|
ae54166ea2 | ||
|
b5007c8f95 | ||
|
634f86c6fa | ||
|
669bd70946 | ||
|
2d81358fa0 | ||
|
386137f16e | ||
|
35d967e6d9 | ||
|
7252dd4f8b | ||
23e5f2fbae | |||
bc78d3247b | |||
|
96c694c2a3 | ||
6960bb85a2 | |||
|
3f5e6e3a70 | ||
|
2d3d1ca69c | ||
|
881defa081 | ||
|
848043d758 | ||
|
c7c4dd5902 | ||
|
53fb8781b3 | ||
|
762485db3a | ||
|
7f848223b9 | ||
|
6e848c35e3 | ||
|
70c2dcde17 | ||
|
e133092831 | ||
|
597dc57bd5 | ||
|
127ffaa1b9 | ||
|
ce8eec71ff | ||
|
b46aec3395 | ||
|
e210808983 | ||
|
613b314631 | ||
|
2acc81cd75 | ||
|
6c409ac9fd | ||
|
7ba90a72c0 | ||
|
054b3b6659 | ||
|
d383328ebb | ||
|
c930dce131 | ||
|
7357d48115 | ||
|
d445dc137a | ||
|
dd3786486c | ||
|
6ed510f2cc | ||
|
382f9c8106 | ||
|
7230b7c67d | ||
|
fbdb481aa0 | ||
|
ac1fd3669f | ||
|
ef618ad754 | ||
|
15c1d5b84a | ||
|
99904683a1 | ||
|
307bbb1b0c | ||
|
f66d7ef142 | ||
|
32419952a8 | ||
|
c4c87ed47b | ||
|
ff17972f5b | ||
|
d51facc0b2 | ||
|
a2eae0ac13 | ||
|
3a38c20f56 | ||
|
b02554778e | ||
|
bb5d1328d6 | ||
|
7de2a6ad77 | ||
|
64a687445d | ||
|
7d639d1b4c | ||
|
12c9918d2c | ||
|
4999cdc498 | ||
|
1db62c404e | ||
|
3a0a420c5a | ||
|
50533b0537 | ||
|
5646b1d5f4 | ||
|
61cd795cc2 | ||
|
394174a244 | ||
|
193feac230 | ||
|
9b92d38318 | ||
|
5b84c99703 | ||
|
a1d905ebea | ||
|
a1354c2862 | ||
|
47f17efcfb | ||
|
b5b3455f08 | ||
|
191a21bec9 | ||
|
85c02e4053 | ||
|
2216910edb | ||
|
16a32cdce7 | ||
|
e6cb512a17 | ||
|
945f5f63c1 | ||
|
8283ecde60 | ||
|
3512dce35b | ||
|
f3cb051535 | ||
|
71273ccc78 | ||
|
503c542a5f | ||
|
03e9c94b3b | ||
|
268000b218 | ||
|
dfd676612e | ||
|
0d48d3768a | ||
|
50c961ec31 | ||
|
c754708213 | ||
|
befa1d4bec | ||
|
f541599fed | ||
|
f707f62ce3 | ||
|
f6702f22e6 | ||
|
2db59942eb | ||
|
c5b4aba548 | ||
|
b9564dc812 | ||
|
2c151ae6c1 | ||
|
3eca8aef2d | ||
|
dcc1293455 | ||
|
23c2d58647 | ||
|
c87da2bb6a | ||
|
2c2f164e18 | ||
|
b677cb880f | ||
|
957d3e32b8 | ||
|
0ea8a55a7f | ||
|
bb518e6259 | ||
|
4801d93bfe | ||
|
7e8a5cc580 | ||
|
e8642d758e | ||
|
3171e054e0 | ||
|
8a7b3d4e22 | ||
|
293ea60919 | ||
|
d2858ff021 | ||
|
4d3a2ea892 | ||
|
a029390c38 | ||
|
8442990a0e | ||
|
0e2673837c | ||
|
ae648b70a4 | ||
|
78b57b3899 | ||
|
ea7c8394a3 | ||
|
d6cd0f1f20 | ||
|
76e95f2859 | ||
|
0e33a09d8f | ||
|
f56a3f84f8 | ||
|
168f324d6b | ||
|
f211b2cded | ||
|
0470578739 | ||
|
deff46d143 | ||
|
a03e0bc574 | ||
|
bf3da0723f | ||
|
d4e388154d | ||
|
370a57dbb9 | ||
|
2705e393df | ||
|
6b97847882 | ||
|
d67fc3b2df | ||
|
7b0228c014 | ||
|
698617b977 | ||
|
1294274951 | ||
|
aefce2d607 | ||
|
6c89403a03 | ||
|
589379dc98 | ||
|
b9a2345c84 |
1770
Cargo.lock
generated
Normal file
1770
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "rustico"
|
||||
version = "0.1.0"
|
||||
authors = ["Giulio De Pasquale <depasquale@giugl.io>"]
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
bitfinex = { path= "/home/giulio/dev/bitfinex-rs" }
|
||||
tokio = { version = "1", features=["full"]}
|
||||
futures-util = { version = "0.3", default-features = false, features = ["async-await", "sink", "std"] }
|
||||
async-trait = "0.1"
|
||||
regex = "1"
|
||||
dyn-clone = "1"
|
||||
log = "0.4"
|
||||
fern = {version = "0.6", features = ["colored"]}
|
||||
chrono = "0.4"
|
||||
byteorder = "1"
|
||||
float-cmp = "0.8"
|
||||
merge = "0.1"
|
||||
futures-retry = "0.6"
|
||||
tungstenite = "0.12"
|
||||
tokio-tungstenite = "0.13"
|
||||
dotenv = "0.15"
|
||||
ears = "0.8.0"
|
12
bfxbot.iml
12
bfxbot.iml
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PackageRequirementsSettings">
|
||||
<option name="removeUnused" value="true" />
|
||||
</component>
|
||||
</module>
|
@ -1 +0,0 @@
|
||||
from .bfxbot import BfxBot
|
151
bfxbot/bfxbot.py
151
bfxbot/bfxbot.py
@ -1,151 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from bfxapi import Order
|
||||
|
||||
from bfxbot.bfxwrapper import BfxWrapper
|
||||
from bfxbot.currency import TradingPair, Symbol
|
||||
from bfxbot.models import SymbolStatus, Ticker, EventHandler, Strategy, Event, EventKind, OFFER_PERC, PositionWrapper
|
||||
|
||||
|
||||
class BfxBot:
|
||||
def __init__(self, api_key: str, api_secret: str, symbols: List[TradingPair], quote: Symbol,
|
||||
tick_duration: int = 1):
|
||||
if api_key is None:
|
||||
print("API_KEY is not set!")
|
||||
raise ValueError
|
||||
|
||||
if api_secret is None:
|
||||
print("API_SECRET is not set!")
|
||||
raise ValueError
|
||||
|
||||
self.__bfx: BfxWrapper = BfxWrapper(api_key, api_secret)
|
||||
self.__ticker: Ticker = Ticker(tick_duration)
|
||||
self.__status: Dict[TradingPair, SymbolStatus] = {}
|
||||
self.__quote: Symbol = quote
|
||||
self.__account_info = None
|
||||
self.__ledger = None
|
||||
|
||||
if isinstance(symbols, TradingPair):
|
||||
symbols = [symbols]
|
||||
|
||||
self.symbols: List[TradingPair] = symbols
|
||||
|
||||
# init symbol statuses
|
||||
for s in self.symbols:
|
||||
self.__status[s] = SymbolStatus(s)
|
||||
|
||||
def __position_wrapper_from_id(self, position_id) -> Tuple[Optional[PositionWrapper], Optional[SymbolStatus]]:
|
||||
for s in self.__status.values():
|
||||
pw = s.active_position_wrapper_from_id(position_id)
|
||||
|
||||
if pw:
|
||||
return pw, s
|
||||
return None, None
|
||||
|
||||
async def __update_status__(self):
|
||||
active_positions = await self.__bfx.get_active_position()
|
||||
|
||||
for symbol in self.__status:
|
||||
# updating tick
|
||||
self.__status[symbol].__init_tick__(self.__ticker.current_tick)
|
||||
|
||||
# updating last price
|
||||
last_price = await self.__bfx.get_current_prices(symbol)
|
||||
last_price = last_price[0]
|
||||
|
||||
self.__status[symbol].set_tick_price(self.__ticker.current_tick, last_price)
|
||||
|
||||
# updating positions
|
||||
symbol_positions = [x for x in active_positions if x.symbol == str(symbol)]
|
||||
for p in symbol_positions:
|
||||
await self.__status[TradingPair.from_str(p.symbol)].add_position(p)
|
||||
|
||||
# updating orders
|
||||
active_orders = await self.__bfx.get_active_orders(symbol)
|
||||
|
||||
for o in active_orders:
|
||||
self.__status[symbol].add_order(o)
|
||||
|
||||
# emitting new tick event
|
||||
# TODO: handle _on_new_tick() from Strategy
|
||||
await self.__status[symbol].add_event(Event(EventKind.NEW_TICK, self.__ticker.current_tick))
|
||||
|
||||
async def best_position_closing_price(self, position_id: int) -> Optional[float]:
|
||||
pw, _ = self.__position_wrapper_from_id(position_id)
|
||||
|
||||
if not pw:
|
||||
return None
|
||||
|
||||
is_long_pos = pw.position.amount < 0
|
||||
|
||||
pub_tick = await self.__bfx.get_public_ticker(pw.position.symbol)
|
||||
|
||||
bid_price = pub_tick[0]
|
||||
ask_price = pub_tick[2]
|
||||
|
||||
if is_long_pos:
|
||||
closing_price = bid_price * (1 - OFFER_PERC / 100)
|
||||
else:
|
||||
closing_price = ask_price * (1 + OFFER_PERC / 100)
|
||||
|
||||
return closing_price
|
||||
|
||||
def close_order(self, symbol: TradingPair, order_id: int):
|
||||
print(f"I would have closed order {order_id} for {symbol}")
|
||||
|
||||
async def close_position(self, position_id: int):
|
||||
pw, ss = self.__position_wrapper_from_id(position_id)
|
||||
|
||||
if not pw:
|
||||
print("Could not find open position!")
|
||||
return
|
||||
|
||||
closing_price = await self.best_position_closing_price(pw.position.id)
|
||||
|
||||
amount = pw.position.amount * -1
|
||||
|
||||
open_orders = await self.__bfx.get_active_orders(pw.position.symbol)
|
||||
|
||||
if not open_orders:
|
||||
await self.__bfx.submit_order(pw.position.symbol, closing_price, amount, Order.Type.LIMIT)
|
||||
await ss.add_event(Event(EventKind.ORDER_SUBMITTED, ss.current_tick))
|
||||
|
||||
async def get_balances(self):
|
||||
return await self.__bfx.get_current_balances(self.__quote)
|
||||
|
||||
async def get_profit_loss(self, start: int, end: int):
|
||||
return await self.__bfx.profit_loss(start, end, self.__ledger, self.__quote)
|
||||
|
||||
def set_strategy(self, symbol, strategy: Strategy):
|
||||
if symbol in self.__status:
|
||||
self.__status[symbol].strategy = strategy
|
||||
else:
|
||||
self.__status[symbol] = SymbolStatus(symbol, strategy)
|
||||
|
||||
async def start(self):
|
||||
self.__account_info = self.__bfx.get_account_information()
|
||||
self.__ledger = await self.__bfx.ledger_history(0, time.time() * 1000)
|
||||
|
||||
await self.__update_status__()
|
||||
|
||||
def symbol_event_handler(self, symbol) -> Optional[EventHandler]:
|
||||
if symbol not in self.__status:
|
||||
return None
|
||||
|
||||
return self.__status[symbol].eh
|
||||
|
||||
def symbol_status(self, symbol: TradingPair) -> Optional[SymbolStatus]:
|
||||
if symbol not in self.__status:
|
||||
return None
|
||||
|
||||
return self.__status[symbol]
|
||||
|
||||
async def update(self):
|
||||
await asyncio.sleep(self.__ticker.seconds)
|
||||
self.__ticker.inc()
|
||||
await self.__update_status__()
|
||||
|
||||
async def __update_ledger(self):
|
||||
self.__ledger = await self.__bfx.ledger_history(0, time.time() * 1000)
|
@ -1,212 +0,0 @@
|
||||
from bfxapi.rest.bfx_rest import BfxRest
|
||||
from retrying_async import retry
|
||||
|
||||
from bfxbot.currency import TradingPair, Balance, WalletKind, OrderType, Direction, Currency, BalanceGroup, Symbol
|
||||
from bfxbot.utils import average
|
||||
|
||||
|
||||
class BfxWrapper(BfxRest):
|
||||
# default timeframe (in milliseconds) when retrieving old prices
|
||||
DEFAULT_TIME_DELTA = 5 * 60 * 1000
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str):
|
||||
super().__init__(API_KEY=api_key, API_SECRET=api_secret)
|
||||
|
||||
#######################################
|
||||
# OVERRIDDEN METHODS TO IMPLEMENT RETRY
|
||||
#######################################
|
||||
|
||||
@retry()
|
||||
async def get_public_ticker(self, symbol):
|
||||
if isinstance(symbol, TradingPair):
|
||||
symbol = str(symbol)
|
||||
|
||||
return await super().get_public_ticker(symbol)
|
||||
|
||||
@retry()
|
||||
async def get_active_position(self):
|
||||
return await super().get_active_position()
|
||||
|
||||
@retry()
|
||||
async def get_active_orders(self, symbol):
|
||||
if isinstance(symbol, TradingPair):
|
||||
symbol = str(symbol)
|
||||
|
||||
return await super().get_active_orders(symbol)
|
||||
|
||||
@retry()
|
||||
async def get_trades(self, symbol, start, end):
|
||||
if isinstance(symbol, TradingPair):
|
||||
symbol = str(symbol)
|
||||
|
||||
return await super().get_trades(symbol, start, end)
|
||||
|
||||
@retry()
|
||||
async def post(self, endpoint: str, data=None, params=""):
|
||||
if data is None:
|
||||
data = {}
|
||||
return await super().post(endpoint, data, params)
|
||||
|
||||
################################
|
||||
# NEW METHODS
|
||||
################################
|
||||
|
||||
async def account_movements_between(self, start: int, end: int, ledger, quote: Symbol) -> BalanceGroup:
|
||||
movements = BalanceGroup(quote)
|
||||
|
||||
# TODO: Parallelize this
|
||||
for entry in filter(lambda x: start <= x[3] <= end, ledger):
|
||||
description: str = entry[8]
|
||||
currency = entry[1]
|
||||
amount = entry[5]
|
||||
time = entry[3]
|
||||
|
||||
if not description.lower().startswith("deposit"):
|
||||
continue
|
||||
|
||||
trading_pair = f"t{currency}{quote}"
|
||||
start_time = time - self.DEFAULT_TIME_DELTA
|
||||
end_time = time + self.DEFAULT_TIME_DELTA
|
||||
|
||||
if currency != str(quote):
|
||||
trades = await self.get_public_trades(symbol=trading_pair, start=start_time, end=end_time)
|
||||
currency_price = average(list(map(lambda x: x[3], trades)))
|
||||
|
||||
c = Currency(currency, amount, currency_price)
|
||||
else:
|
||||
c = Currency(currency, amount)
|
||||
|
||||
b = Balance(c, quote)
|
||||
|
||||
movements.add_balance(b)
|
||||
|
||||
return movements
|
||||
|
||||
async def balance_at(self, time: int, ledger, quote: Symbol):
|
||||
bg = BalanceGroup(quote)
|
||||
|
||||
# TODO: Parallelize this
|
||||
for entry in filter(lambda x: x[3] <= time, ledger):
|
||||
currency = entry[1]
|
||||
amount = entry[6]
|
||||
|
||||
if currency in bg.currency_names():
|
||||
continue
|
||||
|
||||
trading_pair = f"t{currency}{quote}"
|
||||
start_time = time - self.DEFAULT_TIME_DELTA
|
||||
end_time = time + self.DEFAULT_TIME_DELTA
|
||||
|
||||
|
||||
if currency != str(quote):
|
||||
trades = await self.get_public_trades(symbol=trading_pair, start=start_time, end=end_time)
|
||||
currency_price = average(list(map(lambda x: x[3], trades)))
|
||||
|
||||
c = Currency(currency, amount, currency_price)
|
||||
else:
|
||||
c = Currency(currency, amount)
|
||||
|
||||
b = Balance(c, quote)
|
||||
|
||||
bg.add_balance(b)
|
||||
|
||||
return bg
|
||||
|
||||
# Calculate the average execution price for Trading or rate for Margin funding.
|
||||
async def calculate_execution_price(self, pair: str, amount: float):
|
||||
api_path = "/calc/trade/avg"
|
||||
|
||||
res = await self.post(api_path, {
|
||||
'symbol': pair,
|
||||
'amount': amount
|
||||
})
|
||||
|
||||
return res[0]
|
||||
|
||||
async def get_account_information(self):
|
||||
api_path = "auth/r/info/user"
|
||||
|
||||
return await self.post(api_path)
|
||||
|
||||
async def get_current_balances(self, quote: Symbol) -> BalanceGroup:
|
||||
bg: BalanceGroup = BalanceGroup(quote)
|
||||
|
||||
wallets = await self.get_wallets()
|
||||
|
||||
for w in wallets:
|
||||
kind = WalletKind.from_str(w.type)
|
||||
|
||||
if not kind:
|
||||
continue
|
||||
|
||||
execution_price = await self.calculate_execution_price(f"t{w.currency}{quote}", w.balance)
|
||||
c = Currency(w.currency, w.balance, execution_price)
|
||||
b = Balance(c, quote, kind)
|
||||
|
||||
bg.add_balance(b)
|
||||
|
||||
return bg
|
||||
|
||||
async def get_current_prices(self, symbol: TradingPair) -> (float, float, float):
|
||||
if isinstance(symbol, TradingPair):
|
||||
symbol = str(symbol)
|
||||
|
||||
tickers = await self.get_public_ticker(symbol)
|
||||
|
||||
bid_price = tickers[0]
|
||||
ask_price = tickers[2]
|
||||
ticker_price = tickers[6]
|
||||
|
||||
return bid_price, ask_price, ticker_price
|
||||
|
||||
async def ledger_history(self, start, end):
|
||||
def chunks(lst):
|
||||
for i in range(len(lst) - 1):
|
||||
yield lst[i:i + 2]
|
||||
|
||||
def get_timeframes(start, end, increments=10):
|
||||
start = int(start)
|
||||
end = int(end)
|
||||
|
||||
delta = int((end - start) / increments)
|
||||
|
||||
return [x for x in range(start, end, delta)]
|
||||
|
||||
api_path = "auth/r/ledgers/hist"
|
||||
|
||||
history = []
|
||||
|
||||
# TODO: Parallelize this
|
||||
for c in chunks(get_timeframes(start, end)):
|
||||
history.extend(await self.post(api_path, {'start': c[0], 'end': c[1], 'limit': 2500}))
|
||||
|
||||
history.sort(key=lambda ledger_entry: ledger_entry[3], reverse=True)
|
||||
|
||||
return history
|
||||
|
||||
async def maximum_order_amount(self, symbol: TradingPair, direction: Direction,
|
||||
order_type: OrderType = OrderType.EXCHANGE,
|
||||
rate: int = 1):
|
||||
api_path = "auth/calc/order/avail"
|
||||
|
||||
return await self.post(api_path,
|
||||
{'symbol': str(symbol), 'type': order_type.value, "dir": direction.value, "rate": rate})
|
||||
|
||||
async def profit_loss(self, start: int, end: int, ledger, quote: Symbol):
|
||||
if start > end:
|
||||
raise ValueError
|
||||
|
||||
start_bg = await self.balance_at(start, ledger, quote)
|
||||
end_bg = await self.balance_at(end, ledger, quote)
|
||||
movements_bg = await self.account_movements_between(start, end, ledger, quote)
|
||||
|
||||
start_quote = start_bg.quote_equivalent()
|
||||
end_quote = end_bg.quote_equivalent()
|
||||
movements_quote = movements_bg.quote_equivalent()
|
||||
|
||||
profit_loss = end_quote - (start_quote + movements_quote)
|
||||
|
||||
profit_loss_percentage = profit_loss / (start_quote + movements_quote) * 100
|
||||
|
||||
|
||||
return profit_loss, profit_loss_percentage
|
@ -1,161 +0,0 @@
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class Symbol(Enum):
|
||||
XMR = "XMR"
|
||||
BTC = "BTC"
|
||||
ETH = "ETH"
|
||||
USD = "USD"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.value == other.value
|
||||
|
||||
|
||||
class TradingPair(Enum):
|
||||
XMR = "XMR"
|
||||
BTC = "BTC"
|
||||
ETH = "ETH"
|
||||
|
||||
def __repr__(self):
|
||||
return f"t{self.value}USD"
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.value == other.value
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.__repr__())
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str):
|
||||
match = re.compile("t([a-zA-Z]+)USD").match(string)
|
||||
|
||||
if not match:
|
||||
raise ValueError
|
||||
|
||||
currency = match.group(1).lower()
|
||||
|
||||
if currency in ("xmr"):
|
||||
return TradingPair.XMR
|
||||
elif currency in ("btc"):
|
||||
return TradingPair.BTC
|
||||
elif currency in ("eth"):
|
||||
return TradingPair.ETH
|
||||
else:
|
||||
return NotImplementedError
|
||||
|
||||
|
||||
class Currency:
|
||||
def __init__(self, name: str, amount: float, price: float = None):
|
||||
self.__name: str = name
|
||||
self.__amount: float = amount
|
||||
self.__price: Optional[float] = price
|
||||
|
||||
def __str__(self):
|
||||
if self.__price:
|
||||
return f"{self.__name} {self.__amount} @ {self.__price}"
|
||||
else:
|
||||
return f"{self.__name} {self.__amount}"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def amount(self) -> float:
|
||||
return self.__amount
|
||||
|
||||
def name(self) -> str:
|
||||
return self.__name
|
||||
|
||||
def price(self) -> Optional[float]:
|
||||
return self.__price
|
||||
|
||||
|
||||
class WalletKind(Enum):
|
||||
EXCHANGE = "exchange",
|
||||
MARGIN = "margin"
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str):
|
||||
string = string.lower()
|
||||
|
||||
if "margin" in string:
|
||||
return WalletKind.MARGIN
|
||||
if "exchange" in string:
|
||||
return WalletKind.EXCHANGE
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class Balance:
|
||||
def __init__(self, currency: Currency, quote: Symbol, wallet: Optional[WalletKind] = None):
|
||||
self.__currency: Currency = currency
|
||||
self.__quote: Symbol = quote
|
||||
self.__quote_equivalent: float = 0.0
|
||||
self.__wallet: Optional[WalletKind] = wallet
|
||||
|
||||
if currency.name() == str(quote):
|
||||
self.__quote_equivalent = currency.amount()
|
||||
else:
|
||||
self.__quote_equivalent = currency.amount() * currency.price()
|
||||
|
||||
def currency(self) -> Currency:
|
||||
return self.__currency
|
||||
|
||||
def quote(self) -> Symbol:
|
||||
return self.__quote
|
||||
|
||||
def quote_equivalent(self) -> float:
|
||||
return self.__quote_equivalent
|
||||
|
||||
def wallet(self) -> Optional[WalletKind]:
|
||||
return self.__wallet
|
||||
|
||||
|
||||
class BalanceGroup:
|
||||
def __init__(self, quote: Symbol, balances: Optional[List[Balance]] = None):
|
||||
if balances is None:
|
||||
balances = []
|
||||
|
||||
self.__quote: Symbol = quote
|
||||
self.__balances: Optional[List[Balance]] = balances
|
||||
self.__quote_equivalent: float = 0.0
|
||||
|
||||
def __iter__(self):
|
||||
return self.__balances.__iter__()
|
||||
|
||||
def add_balance(self, balance: Balance):
|
||||
self.__balances.append(balance)
|
||||
|
||||
self.__quote_equivalent += balance.quote_equivalent()
|
||||
|
||||
def balances(self) -> Optional[List[Balance]]:
|
||||
return self.__balances
|
||||
|
||||
def currency_names(self) -> List[str]:
|
||||
return list(map(lambda x: x.currency().name(), self.balances()))
|
||||
|
||||
def quote(self) -> Symbol:
|
||||
return self.__quote
|
||||
|
||||
def quote_equivalent(self) -> float:
|
||||
return self.__quote_equivalent
|
||||
|
||||
|
||||
class Direction(Enum):
|
||||
UP = 1,
|
||||
DOWN = -1
|
||||
|
||||
|
||||
class OrderType(Enum):
|
||||
EXCHANGE = "EXCHANGE",
|
||||
MARGIN = "MARGIN"
|
295
bfxbot/models.py
295
bfxbot/models.py
@ -1,295 +0,0 @@
|
||||
import inspect
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
from bfxapi import Order, Position
|
||||
|
||||
from bfxbot.currency import TradingPair
|
||||
|
||||
OFFER_PERC = 0.008
|
||||
TAKER_FEE = 0.2
|
||||
MAKER_FEE = 0.1
|
||||
|
||||
|
||||
def __add_to_dict_list__(dictionary: Dict[int, List], k, v) -> Dict[int, List]:
|
||||
if k not in dictionary:
|
||||
dictionary[k] = [v]
|
||||
else:
|
||||
dictionary[k].append(v)
|
||||
|
||||
return dictionary
|
||||
|
||||
|
||||
class EventKind(Enum):
|
||||
NEW_MINIMUM = 1,
|
||||
NEW_MAXIMUM = 2,
|
||||
REACHED_LOSS = 3,
|
||||
REACHED_BREAK_EVEN = 4,
|
||||
REACHED_MIN_PROFIT = 5,
|
||||
REACHED_GOOD_PROFIT = 6,
|
||||
REACHED_MAX_LOSS = 7,
|
||||
CLOSE_POSITION = 8,
|
||||
TRAILING_STOP_SET = 9,
|
||||
TRAILING_STOP_MOVED = 10,
|
||||
ORDER_SUBMITTED = 11,
|
||||
NEW_TICK = 12
|
||||
|
||||
|
||||
class EventMetadata:
|
||||
def __init__(self, position_id: int = None, order_id: int = None):
|
||||
self.position_id: int = position_id
|
||||
self.order_id: int = order_id
|
||||
|
||||
|
||||
class PositionState(Enum):
|
||||
CRITICAL = -1,
|
||||
LOSS = 0,
|
||||
BREAK_EVEN = 1,
|
||||
MINIMUM_PROFIT = 2,
|
||||
PROFIT = 3,
|
||||
UNDEFINED = 4
|
||||
|
||||
def color(self) -> str:
|
||||
if self == self.LOSS or self == self.CRITICAL:
|
||||
return "red"
|
||||
elif self == self.BREAK_EVEN:
|
||||
return "yellow"
|
||||
else:
|
||||
return "green"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
|
||||
class Ticker:
|
||||
def __init__(self, sec) -> None:
|
||||
self.seconds: int = sec
|
||||
self.start_time = time.time()
|
||||
self.current_tick: int = 1
|
||||
|
||||
def inc(self):
|
||||
self.current_tick += 1
|
||||
|
||||
|
||||
class Event:
|
||||
def __init__(self, kind: EventKind, tick: int, metadata: EventMetadata = None) -> None:
|
||||
self.kind: EventKind = kind
|
||||
self.tick: int = tick
|
||||
self.metadata: EventMetadata = metadata
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.kind.name} @ Tick {self.tick}"
|
||||
|
||||
def has_metadata(self) -> bool:
|
||||
return self.metadata is not None
|
||||
|
||||
|
||||
class PositionWrapper:
|
||||
def __init__(self, position: Position, state: PositionState = PositionState.UNDEFINED,
|
||||
net_profit_loss: float = None,
|
||||
net_profit_loss_percentage: float = None):
|
||||
self.position: Position = position
|
||||
self.__net_profit_loss: float = net_profit_loss
|
||||
self.__net_profit_loss_percentage: float = net_profit_loss_percentage
|
||||
self.__state: PositionState = state
|
||||
|
||||
def net_profit_loss(self) -> float:
|
||||
return self.__net_profit_loss
|
||||
|
||||
def net_profit_loss_percentage(self) -> float:
|
||||
return self.__net_profit_loss_percentage
|
||||
|
||||
def set_state(self, state: PositionState):
|
||||
self.__state = state
|
||||
|
||||
def state(self) -> PositionState:
|
||||
return self.__state
|
||||
|
||||
|
||||
class SymbolStatus:
|
||||
def __init__(self, symbol: TradingPair, strategy=None):
|
||||
self.symbol = symbol
|
||||
self.eh = EventHandler()
|
||||
self.prices: Dict[int, float] = {}
|
||||
self.events: List[Event] = []
|
||||
self.orders: Dict[int, List[Order]] = {}
|
||||
self.positions: Dict[int, List[PositionWrapper]] = {}
|
||||
self.current_tick: int = 1
|
||||
self.strategy: Strategy = strategy
|
||||
|
||||
def __init_tick__(self, tick: int):
|
||||
self.current_tick = tick
|
||||
self.prices[self.current_tick] = None
|
||||
self.orders[self.current_tick] = []
|
||||
self.positions[self.current_tick] = []
|
||||
|
||||
async def add_event(self, event: Event):
|
||||
self.events.append(event)
|
||||
await self.eh.call_event(self, event)
|
||||
|
||||
def add_order(self, order: Order):
|
||||
if self.strategy:
|
||||
self.strategy.order_on_new_tick(order, self)
|
||||
self.orders = __add_to_dict_list__(self.orders, self.current_tick, order)
|
||||
|
||||
# Applies strategy and adds position to list
|
||||
async def add_position(self, position: Position):
|
||||
events = []
|
||||
|
||||
# if a strategy is defined then the strategy takes care of creating a PW for us
|
||||
if not self.strategy:
|
||||
pw = PositionWrapper(position)
|
||||
else:
|
||||
pw, events = await self.__apply_strategy_to_position__(position)
|
||||
self.positions = __add_to_dict_list__(self.positions, self.current_tick, pw)
|
||||
|
||||
# triggering state callbacks
|
||||
await self.__trigger_position_state_callbacks__(pw)
|
||||
|
||||
# triggering events callbacks
|
||||
for e in events:
|
||||
if not isinstance(e, Event):
|
||||
raise ValueError
|
||||
await self.add_event(e)
|
||||
|
||||
def all_prices(self) -> List[float]:
|
||||
return list(map(lambda x: self.prices[x], range(1, self.current_tick + 1)))
|
||||
|
||||
def all_ticks(self) -> List[int]:
|
||||
return [x for x in range(1, self.current_tick + 1)]
|
||||
|
||||
def current_positions(self) -> List[PositionWrapper]:
|
||||
return self.positions[self.current_tick]
|
||||
|
||||
def current_price(self):
|
||||
return self.prices[self.current_tick]
|
||||
|
||||
def previous_pw(self, pid: int) -> Optional[PositionWrapper]:
|
||||
if self.current_tick == 1:
|
||||
return None
|
||||
|
||||
if not self.positions[self.current_tick - 1]:
|
||||
return None
|
||||
|
||||
return next(filter(lambda x: x.position.id == pid, self.positions[self.current_tick - 1]))
|
||||
|
||||
def active_position_wrapper_from_id(self, position_id: int) -> Optional[PositionWrapper]:
|
||||
if self.current_tick in self.positions:
|
||||
for pw in self.positions[self.current_tick]:
|
||||
if pw.position.id == position_id:
|
||||
return pw
|
||||
return None
|
||||
|
||||
def set_tick_price(self, tick, price):
|
||||
self.prices[tick] = price
|
||||
|
||||
async def __apply_strategy_to_position__(self, position: Position) -> Tuple[PositionWrapper, List[Event]]:
|
||||
pw, events = self.strategy.position_on_new_tick(position, self)
|
||||
|
||||
if not isinstance(pw, PositionWrapper):
|
||||
raise ValueError
|
||||
|
||||
if not isinstance(events, list):
|
||||
raise ValueError
|
||||
|
||||
return pw, events
|
||||
|
||||
async def __trigger_position_state_callbacks__(self, pw: PositionWrapper):
|
||||
await self.eh.call_position_state(self, pw)
|
||||
|
||||
|
||||
class Strategy:
|
||||
"""
|
||||
Defines new position state and events after tick.
|
||||
"""
|
||||
|
||||
def position_on_new_tick(self, position: Position, ss: SymbolStatus) -> Tuple[PositionWrapper, List[Event]]:
|
||||
pass
|
||||
|
||||
"""
|
||||
Defines new order state and events after tick.
|
||||
"""
|
||||
|
||||
def order_on_new_tick(self, order: Order, ss: SymbolStatus):
|
||||
pass
|
||||
|
||||
|
||||
class EventHandler:
|
||||
def __init__(self):
|
||||
self.event_handlers = {}
|
||||
self.state_handlers = {}
|
||||
self.any_events = []
|
||||
self.any_state = []
|
||||
|
||||
async def call_event(self, status: SymbolStatus, event: Event):
|
||||
value = event.kind.value
|
||||
|
||||
# print("CALLING EVENT: {}".format(event))
|
||||
if value in self.event_handlers:
|
||||
for h in self.event_handlers[value]:
|
||||
if inspect.iscoroutinefunction(h):
|
||||
await h(event, status)
|
||||
else:
|
||||
h(event, status)
|
||||
|
||||
for h in self.any_events:
|
||||
if inspect.iscoroutinefunction(h):
|
||||
await h(event, status)
|
||||
else:
|
||||
h(event, status)
|
||||
|
||||
async def call_position_state(self, status: SymbolStatus, pw: PositionWrapper):
|
||||
state = pw.state()
|
||||
|
||||
if state in self.state_handlers:
|
||||
for h in self.state_handlers[state]:
|
||||
if inspect.iscoroutinefunction(h):
|
||||
await h(pw, status)
|
||||
else:
|
||||
h(pw, status)
|
||||
|
||||
for h in self.any_state:
|
||||
if inspect.iscoroutinefunction(h):
|
||||
await h(pw, status)
|
||||
else:
|
||||
h(pw, status)
|
||||
|
||||
def on_event(self, kind: EventKind):
|
||||
value = kind.value
|
||||
|
||||
def registerhandler(handler):
|
||||
if value in self.event_handlers:
|
||||
self.event_handlers[value].append(handler)
|
||||
else:
|
||||
self.event_handlers[value] = [handler]
|
||||
return handler
|
||||
|
||||
return registerhandler
|
||||
|
||||
def on_position_state(self, state: PositionState):
|
||||
def registerhandler(handler):
|
||||
if state in self.state_handlers:
|
||||
self.state_handlers[state].append(handler)
|
||||
else:
|
||||
self.state_handlers[state] = [handler]
|
||||
return handler
|
||||
|
||||
return registerhandler
|
||||
|
||||
def on_any_event(self):
|
||||
def registerhandle(handler):
|
||||
self.any_events.append(handler)
|
||||
return handler
|
||||
|
||||
return registerhandle
|
||||
|
||||
def on_any_position_state(self):
|
||||
def registerhandle(handler):
|
||||
self.any_state.append(handler)
|
||||
return handler
|
||||
|
||||
return registerhandle
|
@ -1,59 +0,0 @@
|
||||
import re
|
||||
|
||||
from bfxbot.bfxwrapper import Balance
|
||||
from bfxbot.models import PositionWrapper
|
||||
|
||||
|
||||
class CurrencyPair:
|
||||
def __init__(self, base: str, quote: str):
|
||||
self.base: str = base
|
||||
self.quote: str = quote
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str):
|
||||
symbol_regex = re.compile("t(?P<base>[a-zA-Z]{3})(?P<quote>[a-zA-Z]{3})")
|
||||
|
||||
match = symbol_regex.match(string)
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return CurrencyPair(match.group("base"), match.group("quote"))
|
||||
|
||||
|
||||
def average(a):
|
||||
return sum(a) / len(a)
|
||||
|
||||
|
||||
def balance_to_json(balance: Balance):
|
||||
return {
|
||||
'currency': balance.currency().name(),
|
||||
'amount': balance.currency().amount(),
|
||||
'kind': balance.wallet().value,
|
||||
'quote': balance.quote().value,
|
||||
'quote_equivalent': balance.quote_equivalent()
|
||||
}
|
||||
|
||||
|
||||
def net_pl_percentage(perc: float, reference_fee_perc: float):
|
||||
return perc - reference_fee_perc
|
||||
|
||||
|
||||
def pw_to_posprop(pw: PositionWrapper):
|
||||
pair = CurrencyPair.from_str(pw.position.symbol)
|
||||
|
||||
if not pair:
|
||||
raise ValueError
|
||||
|
||||
return {
|
||||
"id": pw.position.id,
|
||||
"amount": pw.position.amount,
|
||||
"base_price": pw.position.base_price,
|
||||
"state": str(pw.state()),
|
||||
"pair": {
|
||||
"base": pair.base,
|
||||
"quote": pair.quote
|
||||
},
|
||||
"profit_loss": pw.net_profit_loss(),
|
||||
"profit_loss_percentage": pw.net_profit_loss_percentage()
|
||||
}
|
140
main.py
140
main.py
@ -1,140 +0,0 @@
|
||||
# #!/usr/bin/env python
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from time import sleep
|
||||
from typing import List
|
||||
|
||||
import dotenv
|
||||
from flask import Flask, render_template
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
from bfxbot import BfxBot
|
||||
from bfxbot.bfxwrapper import Balance
|
||||
from bfxbot.currency import TradingPair, Symbol
|
||||
from bfxbot.models import PositionWrapper, SymbolStatus, Event, EventKind
|
||||
from bfxbot.utils import pw_to_posprop, balance_to_json
|
||||
from strategy import TrailingStopStrategy
|
||||
|
||||
|
||||
async def bot_loop():
|
||||
await bot.start()
|
||||
|
||||
while True:
|
||||
await bot.update()
|
||||
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
API_KEY = os.getenv("API_KEY")
|
||||
API_SECRET = os.getenv("API_SECRET")
|
||||
|
||||
app = Flask(__name__)
|
||||
socketio = SocketIO(app, async_mode="threading")
|
||||
bot = BfxBot(api_key=API_KEY, api_secret=API_SECRET,
|
||||
symbols=[TradingPair.BTC], quote=Symbol.USD, tick_duration=20)
|
||||
strategy = TrailingStopStrategy()
|
||||
bot.set_strategy(TradingPair.BTC, strategy)
|
||||
btc_eh = bot.symbol_event_handler(TradingPair.BTC)
|
||||
|
||||
# initializing and starting bot on other thread
|
||||
threading.Thread(target=lambda: asyncio.run(bot_loop())).start()
|
||||
|
||||
|
||||
###################################
|
||||
# Flask callbacks
|
||||
###################################
|
||||
|
||||
@app.route('/')
|
||||
def entry():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
###################################
|
||||
# Socker.IO callbacks
|
||||
###################################
|
||||
|
||||
@socketio.on("close_position")
|
||||
def on_close_position(message: dict):
|
||||
position_id = message['position_id']
|
||||
|
||||
loop.run_until_complete(bot.close_position(position_id))
|
||||
|
||||
|
||||
@socketio.on('connect')
|
||||
def on_connect():
|
||||
# sleeping on exception to avoid race condition
|
||||
ticks, prices, positions, balances = [], [], [], []
|
||||
|
||||
while not ticks or not prices:
|
||||
try:
|
||||
ticks = bot.symbol_status(TradingPair.BTC).all_ticks()
|
||||
prices = bot.symbol_status(TradingPair.BTC).all_prices()
|
||||
positions = bot.symbol_status(TradingPair.BTC).current_positions()
|
||||
balances = loop.run_until_complete(bot.get_balances())
|
||||
except KeyError:
|
||||
sleep(1)
|
||||
|
||||
socketio.emit("first_connect",
|
||||
{
|
||||
"ticks": ticks,
|
||||
"prices": prices,
|
||||
"positions": list(map(pw_to_posprop, positions)),
|
||||
"balances": list(map(balance_to_json, balances))
|
||||
})
|
||||
|
||||
|
||||
@socketio.on('get_profit_loss')
|
||||
def on_get_profit_loss(message):
|
||||
start = message['start']
|
||||
end = message['end']
|
||||
|
||||
profit_loss = loop.run_until_complete(bot.get_profit_loss(start, end))
|
||||
|
||||
socketio.emit("put_profit_loss", {
|
||||
"pl": profit_loss[0],
|
||||
"pl_perc": profit_loss[1]
|
||||
})
|
||||
|
||||
###################################
|
||||
# Bot callbacks
|
||||
###################################
|
||||
|
||||
@btc_eh.on_event(EventKind.CLOSE_POSITION)
|
||||
async def on_close_position(event: Event, _):
|
||||
print("CLOSING!")
|
||||
await bot.close_position(event.metadata.position_id)
|
||||
|
||||
|
||||
@btc_eh.on_any_position_state()
|
||||
async def on_any_state(pw: PositionWrapper, ss: SymbolStatus):
|
||||
await strategy.update_stop_percentage(pw, ss)
|
||||
|
||||
|
||||
@btc_eh.on_event(EventKind.NEW_TICK)
|
||||
async def on_new_tick(event: Event, status: SymbolStatus):
|
||||
tick = event.tick
|
||||
price = status.prices[event.tick]
|
||||
|
||||
balances: List[Balance] = await bot.get_balances()
|
||||
positions: List[PositionWrapper] = status.positions[event.tick] if event.tick in status.positions else []
|
||||
|
||||
socketio.emit("new_tick", {"tick": tick,
|
||||
"price": price,
|
||||
"positions": list(map(pw_to_posprop, positions)),
|
||||
"balances": list(map(balance_to_json, balances))})
|
||||
|
||||
|
||||
@btc_eh.on_any_event()
|
||||
def on_any_event(event: Event, _):
|
||||
socketio.emit("new_event", {
|
||||
"tick": event.tick,
|
||||
"kind": event.kind.name
|
||||
})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
socketio.run(app)
|
@ -1,36 +0,0 @@
|
||||
aiohttp==3.7.3
|
||||
astroid==2.4.2
|
||||
async-timeout==3.0.1
|
||||
asyncio==3.4.3
|
||||
attrs==20.3.0
|
||||
bidict==0.21.2
|
||||
bitfinex-api-py==1.1.8
|
||||
chardet==3.0.4
|
||||
click==7.1.2
|
||||
eventemitter==0.2.0
|
||||
Flask==1.1.2
|
||||
Flask-SocketIO==5.0.1
|
||||
idna==2.10
|
||||
isort==5.6.4
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.2
|
||||
lazy-object-proxy==1.4.3
|
||||
MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
mpmath==1.1.0
|
||||
multidict==5.1.0
|
||||
pyee==8.1.0
|
||||
pylint==2.6.0
|
||||
python-dotenv==0.15.0
|
||||
python-engineio==4.0.0
|
||||
python-socketio==5.0.3
|
||||
retrying-async==1.2.0
|
||||
six==1.15.0
|
||||
sympy==1.7.1
|
||||
toml==0.10.2
|
||||
typing-extensions==3.7.4.3
|
||||
websockets==8.1
|
||||
Werkzeug==1.0.1
|
||||
wrapt==1.12.1
|
||||
yarl==1.6.3
|
||||
|
BIN
sounds/1up.mp3
BIN
sounds/1up.mp3
Binary file not shown.
BIN
sounds/coin.mp3
BIN
sounds/coin.mp3
Binary file not shown.
Binary file not shown.
BIN
sounds/smas-smb3_goal.wav
Normal file
BIN
sounds/smas-smb3_goal.wav
Normal file
Binary file not shown.
BIN
sounds/smw2_boing.wav
Normal file
BIN
sounds/smw2_boing.wav
Normal file
Binary file not shown.
BIN
sounds/smw_1-up.wav
Normal file
BIN
sounds/smw_1-up.wav
Normal file
Binary file not shown.
BIN
sounds/smw_power-up.wav
Normal file
BIN
sounds/smw_power-up.wav
Normal file
Binary file not shown.
68
src/bot.rs
Normal file
68
src/bot.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use core::time::Duration;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::BoxError;
|
||||
use crate::connectors::ExchangeDetails;
|
||||
use crate::currency::{Symbol, SymbolPair};
|
||||
use crate::frontend::FrontendManagerHandle;
|
||||
use crate::managers::ExchangeManager;
|
||||
use crate::ticker::Ticker;
|
||||
|
||||
pub struct Rustico {
|
||||
ticker: Ticker,
|
||||
exchange_managers: Vec<ExchangeManager>,
|
||||
frontend_connector: FrontendManagerHandle,
|
||||
}
|
||||
|
||||
impl Rustico {
|
||||
// TODO: change constructor to take SymbolPairs and not Symbol
|
||||
pub fn new(
|
||||
exchanges: Vec<ExchangeDetails>,
|
||||
trading_pairs: Vec<SymbolPair>,
|
||||
tick_duration: Duration,
|
||||
) -> Self {
|
||||
let exchange_managers = exchanges
|
||||
.iter()
|
||||
.map(|x| ExchangeManager::new(x, &trading_pairs))
|
||||
.collect();
|
||||
|
||||
Rustico {
|
||||
ticker: Ticker::new(tick_duration),
|
||||
exchange_managers,
|
||||
frontend_connector: FrontendManagerHandle::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_loop(&mut self) -> Result<(), BoxError> {
|
||||
self.update_exchanges().await?;
|
||||
|
||||
loop {
|
||||
info!("Current tick: {}", self.ticker.current_tick());
|
||||
|
||||
if let Err(e) = self.update().await {
|
||||
error!("Error in main bot loop: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_exchanges(&mut self) -> Result<(), BoxError> {
|
||||
for e in &mut self.exchange_managers {
|
||||
if let Err(err) = e.update_managers(self.ticker.current_tick()).await {
|
||||
error!("Error while updating managers: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&mut self) -> Result<(), BoxError> {
|
||||
sleep(self.ticker.duration()).await;
|
||||
self.ticker.inc();
|
||||
|
||||
self.update_exchanges().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
764
src/connectors.rs
Normal file
764
src/connectors.rs
Normal file
@ -0,0 +1,764 @@
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bitfinex::api::RestClient;
|
||||
use bitfinex::book::BookPrecision;
|
||||
use bitfinex::orders::{CancelOrderForm, OrderMeta};
|
||||
use bitfinex::responses::{OrderResponse, TradeResponse};
|
||||
use bitfinex::ticker::TradingPairTicker;
|
||||
use bitfinex::websockets::WebSocketClient;
|
||||
use futures_retry::RetryPolicy;
|
||||
use log::trace;
|
||||
use tokio::macros::support::Future;
|
||||
use tokio::time::Duration;
|
||||
|
||||
use crate::BoxError;
|
||||
use crate::currency::{Symbol, SymbolPair};
|
||||
use crate::models::{
|
||||
ActiveOrder, OrderBook, OrderBookEntry, OrderDetails, OrderFee, OrderForm, OrderKind, Position,
|
||||
PositionState, PriceTicker, Trade, TradingFees, TradingPlatform, WalletKind,
|
||||
};
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
|
||||
pub enum Exchange {
|
||||
Bitfinex,
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash, Clone, Debug)]
|
||||
pub enum ExchangeDetails {
|
||||
Bitfinex { api_key: String, api_secret: String },
|
||||
}
|
||||
|
||||
/// You do **not** have to wrap the `Client` in an [`Rc`] or [`Arc`] to **reuse** it,
|
||||
/// because it already uses an [`Arc`] internally.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Client {
|
||||
exchange: Exchange,
|
||||
inner: Arc<Box<dyn RestConnector>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(exchange: &ExchangeDetails) -> Self {
|
||||
match exchange {
|
||||
ExchangeDetails::Bitfinex {
|
||||
api_key,
|
||||
api_secret,
|
||||
} => Self {
|
||||
exchange: Exchange::Bitfinex,
|
||||
inner: Arc::new(Box::new(BitfinexConnector::new(api_key, api_secret))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn active_positions(
|
||||
&self,
|
||||
pair: &SymbolPair,
|
||||
) -> Result<Option<Vec<Position>>, BoxError> {
|
||||
// retrieving open positions and order book to calculate effective profit/loss
|
||||
let (positions, order_book, fees) = tokio::join!(
|
||||
self.inner.active_positions(pair),
|
||||
self.inner.order_book(pair),
|
||||
self.inner.trading_fees()
|
||||
);
|
||||
|
||||
let (mut positions, order_book, fees) = (positions?, order_book?, fees?);
|
||||
let (best_ask, best_bid) = (order_book.lowest_ask(), order_book.highest_bid());
|
||||
|
||||
if positions.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let derivative_taker = fees
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
TradingFees::Taker {
|
||||
platform,
|
||||
percentage,
|
||||
} if platform == &TradingPlatform::Derivative => Some(percentage),
|
||||
_ => None,
|
||||
})
|
||||
.next()
|
||||
.ok_or("Could not retrieve derivative taker fee!")?;
|
||||
let margin_taker = fees
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
TradingFees::Taker {
|
||||
platform,
|
||||
percentage,
|
||||
} if platform == &TradingPlatform::Margin => Some(percentage),
|
||||
_ => None,
|
||||
})
|
||||
.next()
|
||||
.ok_or("Could not retrieve margin taker fee!")?;
|
||||
|
||||
// updating positions with effective profit/loss
|
||||
positions.iter_mut().flatten().for_each(|x| {
|
||||
let fee = match x.platform() {
|
||||
TradingPlatform::Funding | TradingPlatform::Exchange => {
|
||||
unimplemented!()
|
||||
}
|
||||
TradingPlatform::Margin => margin_taker,
|
||||
TradingPlatform::Derivative => derivative_taker,
|
||||
};
|
||||
|
||||
if x.is_short() {
|
||||
x.update_profit_loss(best_ask, *fee);
|
||||
} else {
|
||||
x.update_profit_loss(best_bid, *fee);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(positions)
|
||||
}
|
||||
|
||||
pub async fn current_prices(&self, pair: &SymbolPair) -> Result<TradingPairTicker, BoxError> {
|
||||
self.inner.current_prices(pair).await
|
||||
}
|
||||
|
||||
pub async fn active_orders(&self, pair: &SymbolPair) -> Result<Vec<ActiveOrder>, BoxError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.active_orders(pair)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| x.pair() == pair)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn submit_order(&self, order: &OrderForm) -> Result<ActiveOrder, BoxError> {
|
||||
self.inner.submit_order(order).await
|
||||
}
|
||||
|
||||
pub async fn order_book(&self, pair: &SymbolPair) -> Result<OrderBook, BoxError> {
|
||||
self.inner.order_book(pair).await
|
||||
}
|
||||
|
||||
pub async fn cancel_order(&self, order: &ActiveOrder) -> Result<ActiveOrder, BoxError> {
|
||||
self.inner.cancel_order(order).await
|
||||
}
|
||||
|
||||
pub async fn transfer_between_wallets(
|
||||
&self,
|
||||
from: &WalletKind,
|
||||
to: &WalletKind,
|
||||
symbol: Symbol,
|
||||
amount: f64,
|
||||
) -> Result<(), BoxError> {
|
||||
self.inner
|
||||
.transfer_between_wallets(from, to, symbol, amount)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn trades_from_order(
|
||||
&self,
|
||||
order: &OrderDetails,
|
||||
) -> Result<Option<Vec<Trade>>, BoxError> {
|
||||
self.inner.trades_from_order(order).await
|
||||
}
|
||||
|
||||
pub async fn orders_history(
|
||||
&self,
|
||||
pair: &SymbolPair,
|
||||
) -> Result<Option<Vec<OrderDetails>>, BoxError> {
|
||||
self.inner.orders_history(pair).await
|
||||
}
|
||||
|
||||
pub async fn trading_fees(&self) -> Result<Vec<TradingFees>, BoxError> { self.inner.trading_fees().await }
|
||||
}
|
||||
|
||||
/// This trait represents a REST API service.
|
||||
#[async_trait]
|
||||
pub trait RestConnector: Send + Sync {
|
||||
fn name(&self) -> String;
|
||||
async fn active_positions(&self, pair: &SymbolPair) -> Result<Option<Vec<Position>>, BoxError>;
|
||||
async fn current_prices(&self, pair: &SymbolPair) -> Result<TradingPairTicker, BoxError>;
|
||||
async fn order_book(&self, pair: &SymbolPair) -> Result<OrderBook, BoxError>;
|
||||
async fn active_orders(&self, pair: &SymbolPair) -> Result<Vec<ActiveOrder>, BoxError>;
|
||||
async fn submit_order(&self, order: &OrderForm) -> Result<ActiveOrder, BoxError>;
|
||||
async fn cancel_order(&self, order: &ActiveOrder) -> Result<ActiveOrder, BoxError>;
|
||||
async fn transfer_between_wallets(
|
||||
&self,
|
||||
from: &WalletKind,
|
||||
to: &WalletKind,
|
||||
symbol: Symbol,
|
||||
amount: f64,
|
||||
) -> Result<(), BoxError>;
|
||||
async fn trades_from_order(&self, order: &OrderDetails)
|
||||
-> Result<Option<Vec<Trade>>, BoxError>;
|
||||
async fn orders_history(
|
||||
&self,
|
||||
pair: &SymbolPair,
|
||||
) -> Result<Option<Vec<OrderDetails>>, BoxError>;
|
||||
async fn trading_fees(&self) -> Result<Vec<TradingFees>, BoxError>;
|
||||
}
|
||||
|
||||
impl Debug for dyn RestConnector {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
/// This trait represents a WebSocket API service.
|
||||
#[async_trait]
|
||||
pub trait WebSocketConnector: Send + Sync {
|
||||
fn name(&self) -> String;
|
||||
async fn connect(&self) -> Result<(), BoxError>;
|
||||
}
|
||||
|
||||
/**************
|
||||
* BITFINEX
|
||||
**************/
|
||||
|
||||
pub struct BitfinexConnector {
|
||||
rest: bitfinex::api::RestClient,
|
||||
ws: bitfinex::websockets::WebSocketClient,
|
||||
}
|
||||
|
||||
impl BitfinexConnector {
|
||||
const AFFILIATE_CODE: &'static str = "XPebOgHxA";
|
||||
|
||||
fn handle_small_nonce_error(e: BoxError) -> RetryPolicy<BoxError> {
|
||||
if e.to_string().contains("nonce: small") {
|
||||
return RetryPolicy::WaitRetry(Duration::from_millis(1));
|
||||
}
|
||||
RetryPolicy::ForwardError(e)
|
||||
}
|
||||
|
||||
pub fn new(api_key: &str, api_secret: &str) -> Self {
|
||||
BitfinexConnector {
|
||||
rest: RestClient::new(Some(api_key.into()), Some(api_secret.into())),
|
||||
ws: WebSocketClient::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_trading_pair(pair: &SymbolPair) -> String {
|
||||
if pair.to_string().to_lowercase().contains("test")
|
||||
|| pair.to_string().to_lowercase().contains("f0")
|
||||
{
|
||||
format!("{}:{}", pair.base(), pair.quote())
|
||||
} else {
|
||||
format!("{}{}", pair.base(), pair.quote())
|
||||
}
|
||||
}
|
||||
|
||||
// retry to submit the request until it succeeds.
|
||||
// the function may fail due to concurrent signed requests
|
||||
// parsed in different times by the server
|
||||
async fn retry_nonce<F, Fut, O>(mut func: F) -> Result<O, BoxError>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output=Result<O, BoxError>>,
|
||||
{
|
||||
let response = {
|
||||
loop {
|
||||
match func().await {
|
||||
Ok(response) => break response,
|
||||
Err(e) => {
|
||||
if !e.to_string().contains("nonce: small") {
|
||||
return Err(e);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_nanos(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RestConnector for BitfinexConnector {
|
||||
fn name(&self) -> String {
|
||||
"Bitfinex REST".into()
|
||||
}
|
||||
|
||||
async fn active_positions(&self, pair: &SymbolPair) -> Result<Option<Vec<Position>>, BoxError> {
|
||||
let active_positions =
|
||||
BitfinexConnector::retry_nonce(|| self.rest.positions.active_positions()).await?;
|
||||
|
||||
let positions: Vec<_> = active_positions
|
||||
.into_iter()
|
||||
.filter_map(|x| x.try_into().ok())
|
||||
.filter(|x: &Position| x.pair() == pair)
|
||||
.collect();
|
||||
|
||||
trace!("\tRetrieved positions for {}", pair);
|
||||
Ok((!positions.is_empty()).then_some(positions))
|
||||
}
|
||||
|
||||
async fn current_prices(&self, pair: &SymbolPair) -> Result<TradingPairTicker, BoxError> {
|
||||
let symbol_name = BitfinexConnector::format_trading_pair(pair);
|
||||
|
||||
let ticker: TradingPairTicker = self.rest.ticker.trading_pair(symbol_name).await?;
|
||||
|
||||
Ok(ticker)
|
||||
}
|
||||
|
||||
async fn order_book(&self, pair: &SymbolPair) -> Result<OrderBook, BoxError> {
|
||||
let symbol_name = BitfinexConnector::format_trading_pair(pair);
|
||||
|
||||
let response = BitfinexConnector::retry_nonce(|| {
|
||||
self.rest.book.trading_pair(&symbol_name, BookPrecision::P0)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let entries = response
|
||||
.into_iter()
|
||||
.map(|x| OrderBookEntry::Trading {
|
||||
price: x.price,
|
||||
count: x.count as u64,
|
||||
amount: x.amount,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(OrderBook::new(pair.clone()).with_entries(entries))
|
||||
}
|
||||
|
||||
async fn active_orders(&self, _: &SymbolPair) -> Result<Vec<ActiveOrder>, BoxError> {
|
||||
let response = BitfinexConnector::retry_nonce(|| self.rest.orders.active_orders()).await?;
|
||||
|
||||
Ok(response.iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn submit_order(&self, order: &OrderForm) -> Result<ActiveOrder, BoxError> {
|
||||
let symbol_name = format!("t{}", BitfinexConnector::format_trading_pair(order.pair()));
|
||||
let amount = order.amount();
|
||||
|
||||
let order_form = {
|
||||
match order.kind() {
|
||||
OrderKind::Limit { price } => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, price, amount, order.into())
|
||||
}
|
||||
OrderKind::Market => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, 0.0, amount, order.into())
|
||||
}
|
||||
OrderKind::Stop { price } => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, price, amount, order.into())
|
||||
}
|
||||
OrderKind::StopLimit { stop_price: price, limit_price } => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, price, amount, order.into())
|
||||
.with_price_aux_limit(Some(limit_price))?
|
||||
}
|
||||
OrderKind::TrailingStop { distance } => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, 0.0, amount, order.into())
|
||||
.with_price_trailing(Some(distance))?
|
||||
}
|
||||
OrderKind::FillOrKill { price } => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, price, amount, order.into())
|
||||
}
|
||||
OrderKind::ImmediateOrCancel { price } => {
|
||||
bitfinex::orders::OrderForm::new(symbol_name, price, amount, order.into())
|
||||
}
|
||||
}
|
||||
.with_meta(Some(OrderMeta::new(
|
||||
BitfinexConnector::AFFILIATE_CODE.to_string(),
|
||||
)))
|
||||
// TODO: CHANGEME!
|
||||
.with_leverage(Some(15))
|
||||
};
|
||||
|
||||
let response =
|
||||
BitfinexConnector::retry_nonce(|| self.rest.orders.submit_order(&order_form)).await?;
|
||||
|
||||
// parsing response into ActiveOrder and adding leverage from order form
|
||||
let order_response: ActiveOrder = (&response).try_into()?;
|
||||
|
||||
// TODO: CHANGEME!!!!
|
||||
Ok(order_response.with_leverage(Some(15.0)))
|
||||
}
|
||||
|
||||
async fn cancel_order(&self, order: &ActiveOrder) -> Result<ActiveOrder, BoxError> {
|
||||
let cancel_form = order.into();
|
||||
|
||||
let response =
|
||||
BitfinexConnector::retry_nonce(|| self.rest.orders.cancel_order(&cancel_form)).await?;
|
||||
|
||||
Ok((&response).try_into()?)
|
||||
}
|
||||
|
||||
async fn transfer_between_wallets(
|
||||
&self,
|
||||
from: &WalletKind,
|
||||
to: &WalletKind,
|
||||
symbol: Symbol,
|
||||
amount: f64,
|
||||
) -> Result<(), BoxError> {
|
||||
BitfinexConnector::retry_nonce(|| {
|
||||
self.rest.account.transfer_between_wallets(
|
||||
from.into(),
|
||||
to.into(),
|
||||
symbol.to_string(),
|
||||
amount,
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn trades_from_order(
|
||||
&self,
|
||||
order: &OrderDetails,
|
||||
) -> Result<Option<Vec<Trade>>, BoxError> {
|
||||
let response = BitfinexConnector::retry_nonce(|| {
|
||||
self.rest
|
||||
.trades
|
||||
.generated_by_order(order.pair().trading_repr(), order.id())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if response.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(response.iter().map(Into::into).collect()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn orders_history(
|
||||
&self,
|
||||
pair: &SymbolPair,
|
||||
) -> Result<Option<Vec<OrderDetails>>, BoxError> {
|
||||
let response =
|
||||
BitfinexConnector::retry_nonce(|| self.rest.orders.history(Some(pair.trading_repr())))
|
||||
.await?;
|
||||
let mapped_vec: Vec<_> = response.iter().map(Into::into).collect();
|
||||
|
||||
Ok((!mapped_vec.is_empty()).then_some(mapped_vec))
|
||||
}
|
||||
|
||||
async fn trading_fees(&self) -> Result<Vec<TradingFees>, BoxError> {
|
||||
let mut fees = vec![];
|
||||
let accountfees =
|
||||
BitfinexConnector::retry_nonce(|| self.rest.account.account_summary()).await?;
|
||||
|
||||
// Derivatives
|
||||
let derivative_taker = TradingFees::Taker {
|
||||
platform: TradingPlatform::Derivative,
|
||||
percentage: accountfees.derivative_taker() * 100.0,
|
||||
};
|
||||
let derivative_maker = TradingFees::Maker {
|
||||
platform: TradingPlatform::Derivative,
|
||||
percentage: accountfees.derivative_rebate() * 100.0,
|
||||
};
|
||||
fees.push(derivative_taker);
|
||||
fees.push(derivative_maker);
|
||||
|
||||
// Exchange
|
||||
let exchange_taker = TradingFees::Taker {
|
||||
platform: TradingPlatform::Exchange,
|
||||
percentage: accountfees.taker_to_fiat() * 100.0,
|
||||
};
|
||||
let exchange_maker = TradingFees::Maker {
|
||||
platform: TradingPlatform::Exchange,
|
||||
percentage: accountfees.maker_fee() * 100.0,
|
||||
};
|
||||
fees.push(exchange_taker);
|
||||
fees.push(exchange_maker);
|
||||
|
||||
// Margin
|
||||
let margin_taker = TradingFees::Taker {
|
||||
platform: TradingPlatform::Margin,
|
||||
percentage: accountfees.taker_to_fiat() * 100.0,
|
||||
};
|
||||
let margin_maker = TradingFees::Maker {
|
||||
platform: TradingPlatform::Margin,
|
||||
percentage: accountfees.maker_fee() * 100.0,
|
||||
};
|
||||
fees.push(margin_taker);
|
||||
fees.push(margin_maker);
|
||||
|
||||
Ok(fees)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebSocketConnector for BitfinexConnector {
|
||||
fn name(&self) -> String {
|
||||
"Bitfinex WS".into()
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<(), BoxError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ActiveOrder> for CancelOrderForm {
|
||||
fn from(o: &ActiveOrder) -> Self {
|
||||
Self::from_id(o.id())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&bitfinex::responses::OrderResponse> for ActiveOrder {
|
||||
type Error = BoxError;
|
||||
|
||||
fn try_from(response: &OrderResponse) -> Result<Self, Self::Error> {
|
||||
let pair = SymbolPair::from_str(response.symbol())?;
|
||||
|
||||
Ok(ActiveOrder::new(
|
||||
Exchange::Bitfinex,
|
||||
response.id(),
|
||||
pair.clone(),
|
||||
OrderForm::new(pair, response.into(), response.into(), response.amount()),
|
||||
response.mts_create(),
|
||||
response.mts_update(),
|
||||
)
|
||||
.with_group_id(response.gid())
|
||||
.with_client_id(Some(response.cid())))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<Position> for bitfinex::positions::Position {
|
||||
type Error = BoxError;
|
||||
|
||||
fn try_into(self) -> Result<Position, Self::Error> {
|
||||
let state = {
|
||||
if self.status().to_lowercase().contains("active") {
|
||||
PositionState::Open
|
||||
} else {
|
||||
PositionState::Closed
|
||||
}
|
||||
};
|
||||
|
||||
let platform = {
|
||||
if self.symbol().to_ascii_lowercase().contains("f0") {
|
||||
TradingPlatform::Derivative
|
||||
} else {
|
||||
TradingPlatform::Margin
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Position::new(
|
||||
SymbolPair::from_str(self.symbol())?,
|
||||
state,
|
||||
self.amount(),
|
||||
self.base_price(),
|
||||
self.pl(),
|
||||
self.pl_perc(),
|
||||
self.price_liq(),
|
||||
self.position_id(),
|
||||
platform,
|
||||
self.leverage(),
|
||||
)
|
||||
.with_creation_date(self.mts_create())
|
||||
.with_creation_update(self.mts_update()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&OrderForm> for bitfinex::orders::OrderKind {
|
||||
fn from(o: &OrderForm) -> Self {
|
||||
match o.platform() {
|
||||
TradingPlatform::Exchange => match o.kind() {
|
||||
OrderKind::Limit { .. } => bitfinex::orders::OrderKind::ExchangeLimit,
|
||||
OrderKind::Market { .. } => bitfinex::orders::OrderKind::ExchangeMarket,
|
||||
OrderKind::Stop { .. } => bitfinex::orders::OrderKind::ExchangeStop,
|
||||
OrderKind::StopLimit { .. } => bitfinex::orders::OrderKind::ExchangeStopLimit,
|
||||
OrderKind::TrailingStop { .. } => bitfinex::orders::OrderKind::ExchangeTrailingStop,
|
||||
OrderKind::FillOrKill { .. } => bitfinex::orders::OrderKind::ExchangeFok,
|
||||
OrderKind::ImmediateOrCancel { .. } => bitfinex::orders::OrderKind::ExchangeIoc,
|
||||
},
|
||||
TradingPlatform::Margin | TradingPlatform::Derivative => match o.kind() {
|
||||
OrderKind::Limit { .. } => bitfinex::orders::OrderKind::Limit,
|
||||
OrderKind::Market { .. } => bitfinex::orders::OrderKind::Market,
|
||||
OrderKind::Stop { .. } => bitfinex::orders::OrderKind::Stop,
|
||||
OrderKind::StopLimit { .. } => bitfinex::orders::OrderKind::StopLimit,
|
||||
OrderKind::TrailingStop { .. } => bitfinex::orders::OrderKind::TrailingStop,
|
||||
OrderKind::FillOrKill { .. } => bitfinex::orders::OrderKind::Fok,
|
||||
OrderKind::ImmediateOrCancel { .. } => bitfinex::orders::OrderKind::Ioc,
|
||||
},
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&bitfinex::responses::OrderResponse> for TradingPlatform {
|
||||
fn from(response: &OrderResponse) -> Self {
|
||||
match response.order_type() {
|
||||
bitfinex::orders::OrderKind::Limit
|
||||
| bitfinex::orders::OrderKind::Market
|
||||
| bitfinex::orders::OrderKind::StopLimit
|
||||
| bitfinex::orders::OrderKind::Stop
|
||||
| bitfinex::orders::OrderKind::TrailingStop
|
||||
| bitfinex::orders::OrderKind::Fok
|
||||
| bitfinex::orders::OrderKind::Ioc => Self::Margin,
|
||||
_ => Self::Exchange,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&bitfinex::orders::ActiveOrder> for TradingPlatform {
|
||||
fn from(response: &bitfinex::orders::ActiveOrder) -> Self {
|
||||
match response.order_type() {
|
||||
bitfinex::orders::OrderKind::Limit
|
||||
| bitfinex::orders::OrderKind::Market
|
||||
| bitfinex::orders::OrderKind::StopLimit
|
||||
| bitfinex::orders::OrderKind::Stop
|
||||
| bitfinex::orders::OrderKind::TrailingStop
|
||||
| bitfinex::orders::OrderKind::Fok
|
||||
| bitfinex::orders::OrderKind::Ioc => Self::Margin,
|
||||
_ => Self::Exchange,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&bitfinex::responses::OrderResponse> for OrderKind {
|
||||
fn from(response: &OrderResponse) -> Self {
|
||||
match response.order_type() {
|
||||
bitfinex::orders::OrderKind::Limit | bitfinex::orders::OrderKind::ExchangeLimit => {
|
||||
Self::Limit {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
bitfinex::orders::OrderKind::Market | bitfinex::orders::OrderKind::ExchangeMarket => {
|
||||
Self::Market
|
||||
}
|
||||
bitfinex::orders::OrderKind::Stop | bitfinex::orders::OrderKind::ExchangeStop => {
|
||||
Self::Stop {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
bitfinex::orders::OrderKind::StopLimit
|
||||
| bitfinex::orders::OrderKind::ExchangeStopLimit => Self::StopLimit {
|
||||
stop_price: response.price(),
|
||||
limit_price: response.price_aux_limit().expect("Limit price not found!"),
|
||||
},
|
||||
bitfinex::orders::OrderKind::TrailingStop
|
||||
| bitfinex::orders::OrderKind::ExchangeTrailingStop => Self::TrailingStop {
|
||||
distance: response.price_trailing().expect("Distance not found!"),
|
||||
},
|
||||
bitfinex::orders::OrderKind::Fok | bitfinex::orders::OrderKind::ExchangeFok => {
|
||||
Self::FillOrKill {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
bitfinex::orders::OrderKind::Ioc | bitfinex::orders::OrderKind::ExchangeIoc => {
|
||||
Self::ImmediateOrCancel {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&bitfinex::orders::ActiveOrder> for OrderKind {
|
||||
fn from(response: &bitfinex::orders::ActiveOrder) -> Self {
|
||||
match response.order_type() {
|
||||
bitfinex::orders::OrderKind::Limit | bitfinex::orders::OrderKind::ExchangeLimit => {
|
||||
Self::Limit {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
bitfinex::orders::OrderKind::Market | bitfinex::orders::OrderKind::ExchangeMarket => {
|
||||
Self::Market {}
|
||||
}
|
||||
bitfinex::orders::OrderKind::Stop | bitfinex::orders::OrderKind::ExchangeStop => {
|
||||
Self::Stop {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
bitfinex::orders::OrderKind::StopLimit
|
||||
| bitfinex::orders::OrderKind::ExchangeStopLimit => Self::StopLimit {
|
||||
stop_price: response.price(),
|
||||
limit_price: response.price_aux_limit().expect("Limit price not found!"),
|
||||
},
|
||||
bitfinex::orders::OrderKind::TrailingStop
|
||||
| bitfinex::orders::OrderKind::ExchangeTrailingStop => Self::TrailingStop {
|
||||
distance: response.price_trailing().expect("Distance not found!"),
|
||||
},
|
||||
bitfinex::orders::OrderKind::Fok | bitfinex::orders::OrderKind::ExchangeFok => {
|
||||
Self::FillOrKill {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
bitfinex::orders::OrderKind::Ioc | bitfinex::orders::OrderKind::ExchangeIoc => {
|
||||
Self::ImmediateOrCancel {
|
||||
price: response.price(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&bitfinex::orders::ActiveOrder> for ActiveOrder {
|
||||
fn from(order: &bitfinex::orders::ActiveOrder) -> Self {
|
||||
let pair = SymbolPair::from_str(&order.symbol()).expect("Invalid symbol!");
|
||||
|
||||
ActiveOrder::new(
|
||||
Exchange::Bitfinex,
|
||||
order.id(),
|
||||
pair.clone(),
|
||||
OrderForm::new(pair, order.into(), order.into(), order.amount()),
|
||||
order.creation_timestamp(),
|
||||
order.update_timestamp(),
|
||||
)
|
||||
.with_client_id(Some(order.client_id()))
|
||||
.with_group_id(order.group_id())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TradingPairTicker> for PriceTicker {
|
||||
fn from(t: TradingPairTicker) -> Self {
|
||||
Self {
|
||||
bid: t.bid,
|
||||
bid_size: t.bid_size,
|
||||
ask: t.ask,
|
||||
ask_size: t.ask_size,
|
||||
daily_change: t.daily_change,
|
||||
daily_change_perc: t.daily_change_perc,
|
||||
last_price: t.last_price,
|
||||
volume: t.volume,
|
||||
high: t.high,
|
||||
low: t.low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&WalletKind> for &bitfinex::account::WalletKind {
|
||||
fn from(k: &WalletKind) -> Self {
|
||||
match k {
|
||||
WalletKind::Exchange => &bitfinex::account::WalletKind::Exchange,
|
||||
WalletKind::Margin => &bitfinex::account::WalletKind::Margin,
|
||||
WalletKind::Funding => &bitfinex::account::WalletKind::Funding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&bitfinex::orders::ActiveOrder> for OrderDetails {
|
||||
fn from(order: &bitfinex::orders::ActiveOrder) -> Self {
|
||||
Self::new(
|
||||
Exchange::Bitfinex,
|
||||
order.id(),
|
||||
SymbolPair::from_str(order.symbol()).unwrap(),
|
||||
order.into(),
|
||||
order.into(),
|
||||
order.update_timestamp(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: fields are hardcoded, to fix
|
||||
impl From<&bitfinex::responses::TradeResponse> for Trade {
|
||||
fn from(response: &TradeResponse) -> Self {
|
||||
let pair = SymbolPair::from_str(&response.symbol()).unwrap();
|
||||
let fee = {
|
||||
if response.is_maker() {
|
||||
OrderFee::Maker(response.fee())
|
||||
} else {
|
||||
OrderFee::Taker(response.fee())
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
trade_id: response.trade_id(),
|
||||
pair,
|
||||
execution_timestamp: response.execution_timestamp(),
|
||||
price: response.execution_price(),
|
||||
amount: response.execution_amount(),
|
||||
fee,
|
||||
fee_currency: Symbol::new(response.symbol().to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
198
src/currency.rs
Normal file
198
src/currency.rs
Normal file
@ -0,0 +1,198 @@
|
||||
use core::fmt;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::BoxError;
|
||||
|
||||
#[derive(Clone, PartialEq, Hash, Debug, Eq)]
|
||||
pub struct Symbol {
|
||||
name: Cow<'static, str>,
|
||||
}
|
||||
|
||||
impl<S> From<S> for Symbol
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
fn from(item: S) -> Self {
|
||||
Symbol::new(item.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Symbol {
|
||||
pub const XMR: Symbol = Symbol::new_static("XMR");
|
||||
pub const BTC: Symbol = Symbol::new_static("BTC");
|
||||
pub const ETH: Symbol = Symbol::new_static("ETH");
|
||||
pub const LTC: Symbol = Symbol::new_static("LTC");
|
||||
pub const DOT: Symbol = Symbol::new_static("DOT");
|
||||
|
||||
pub const DERIV_BTC: Symbol = Symbol::new_static("BTCF0");
|
||||
pub const DERIV_ETH: Symbol = Symbol::new_static("ETHF0");
|
||||
pub const DERIV_USDT: Symbol = Symbol::new_static("USTF0");
|
||||
pub const DERIV_ADA: Symbol = Symbol::new_static("ADAF0");
|
||||
pub const DERIV_POLKADOT: Symbol = Symbol::new_static("DOTF0");
|
||||
|
||||
// Paper trading
|
||||
pub const TESTBTC: Symbol = Symbol::new_static("TESTBTC");
|
||||
pub const TESTUSD: Symbol = Symbol::new_static("TESTUSD");
|
||||
|
||||
pub const DERIV_TESTBTC: Symbol = Symbol::new_static("TESTBTCF0");
|
||||
pub const DERIV_TESTUSDT: Symbol = Symbol::new_static("TESTUSDTF0");
|
||||
|
||||
// Fiat coins
|
||||
pub const USD: Symbol = Symbol::new_static("USD");
|
||||
pub const GBP: Symbol = Symbol::new_static("GBP");
|
||||
pub const EUR: Symbol = Symbol::new_static("EUR");
|
||||
|
||||
pub fn new(name: String) -> Self {
|
||||
Symbol {
|
||||
name: Cow::from(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn new_static(name: &'static str) -> Self {
|
||||
Symbol {
|
||||
name: Cow::Borrowed(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Symbol {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SymbolPair {
|
||||
quote: Symbol,
|
||||
base: Symbol,
|
||||
}
|
||||
|
||||
impl SymbolPair {
|
||||
pub fn new(base: Symbol, quote: Symbol) -> Self {
|
||||
SymbolPair { base, quote }
|
||||
}
|
||||
pub fn trading_repr(&self) -> String {
|
||||
format!("t{}{}", self.base, self.quote)
|
||||
}
|
||||
pub fn funding_repr(&self) -> String {
|
||||
format!("f{}{}", self.base, self.quote)
|
||||
}
|
||||
pub fn quote(&self) -> &Symbol {
|
||||
&self.quote
|
||||
}
|
||||
pub fn base(&self) -> &Symbol {
|
||||
&self.base
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<String> for SymbolPair {
|
||||
fn into(self) -> String {
|
||||
format!("{}/{}", self.base, self.quote)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SymbolPair {
|
||||
type Err = BoxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
const REGEX: &str = r"^[t|f](?P<base>\w{3,7}):?(?P<quote>\w{3,7})";
|
||||
|
||||
let captures = Regex::new(REGEX)?.captures(&value).ok_or("Invalid input")?;
|
||||
let quote = captures.name("quote").ok_or("Quote not found")?.as_str();
|
||||
let base = captures.name("base").ok_or("Base not found")?.as_str();
|
||||
|
||||
Ok(SymbolPair {
|
||||
quote: quote.into(),
|
||||
base: base.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SymbolPair {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.base, self.quote)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum WalletKind {
|
||||
Margin,
|
||||
Exchange,
|
||||
Funding,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Balance {
|
||||
pair: SymbolPair,
|
||||
base_price: f64,
|
||||
base_amount: f64,
|
||||
quote_equivalent: f64,
|
||||
wallet: WalletKind,
|
||||
}
|
||||
|
||||
impl Balance {
|
||||
pub fn new(pair: SymbolPair, base_price: f64, base_amount: f64, wallet: WalletKind) -> Self {
|
||||
Balance {
|
||||
pair,
|
||||
base_price,
|
||||
base_amount,
|
||||
quote_equivalent: base_amount * base_price,
|
||||
wallet,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
pub fn base_price(&self) -> f64 {
|
||||
self.base_price
|
||||
}
|
||||
pub fn base_amount(&self) -> f64 {
|
||||
self.base_amount
|
||||
}
|
||||
pub fn quote_equivalent(&self) -> f64 {
|
||||
self.quote_equivalent
|
||||
}
|
||||
pub fn wallet(&self) -> &WalletKind {
|
||||
&self.wallet
|
||||
}
|
||||
}
|
||||
|
||||
struct BalanceGroup {
|
||||
quote_equivalent: f64,
|
||||
balances: Vec<Balance>,
|
||||
}
|
||||
|
||||
impl BalanceGroup {
|
||||
pub fn new() -> Self {
|
||||
BalanceGroup {
|
||||
balances: Vec::new(),
|
||||
quote_equivalent: 0f64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_balance(&mut self, balance: &Balance) {
|
||||
self.balances.push(balance.clone());
|
||||
|
||||
self.quote_equivalent += balance.quote_equivalent()
|
||||
}
|
||||
|
||||
pub fn currency_names(&self) -> Vec<String> {
|
||||
self.balances
|
||||
.iter()
|
||||
.map(|x| x.pair().base().name().into())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn balances(&self) -> &Vec<Balance> {
|
||||
&self.balances
|
||||
}
|
||||
}
|
75
src/events.rs
Normal file
75
src/events.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::managers::OptionUpdate;
|
||||
use crate::models::OrderForm;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ActorMessage {
|
||||
pub(crate) message: ActionMessage,
|
||||
pub(crate) respond_to: oneshot::Sender<OptionUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ActionMessage {
|
||||
Update { tick: u64 },
|
||||
ClosePosition { position_id: u64 },
|
||||
SubmitOrder { order: OrderForm },
|
||||
ClosePositionOrders { position_id: u64 },
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct EventMetadata {
|
||||
position_id: Option<u64>,
|
||||
order_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl EventMetadata {
|
||||
pub fn new(position_id: Option<u64>, order_id: Option<u64>) -> Self {
|
||||
EventMetadata {
|
||||
position_id,
|
||||
order_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum EventKind {
|
||||
NewMinimum,
|
||||
NewMaximum,
|
||||
ReachedLoss,
|
||||
ReachedBreakEven,
|
||||
ReachedMinProfit,
|
||||
ReachedGoodProfit,
|
||||
ReachedMaxLoss,
|
||||
TrailingStopSet,
|
||||
TrailingStopMoved,
|
||||
OrderSubmitted,
|
||||
NewTick,
|
||||
PositionClosed { position_id: u64 },
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct Event {
|
||||
kind: EventKind,
|
||||
tick: u64,
|
||||
metadata: Option<EventMetadata>,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn new(kind: EventKind, tick: u64) -> Self {
|
||||
Event {
|
||||
kind,
|
||||
tick,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_metadata(mut self, metadata: Option<EventMetadata>) -> Self {
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
|
||||
fn has_metadata(&self) -> bool {
|
||||
self.metadata.is_some()
|
||||
}
|
||||
}
|
89
src/frontend.rs
Normal file
89
src/frontend.rs
Normal file
@ -0,0 +1,89 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use futures_util::stream::TryStreamExt;
|
||||
use futures_util::StreamExt;
|
||||
use log::info;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::mpsc::{channel, Receiver, Sender};
|
||||
use tokio_tungstenite::accept_async;
|
||||
|
||||
use crate::BoxError;
|
||||
use crate::events::ActorMessage;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FrontendManager {
|
||||
receiver: Receiver<ActorMessage>,
|
||||
}
|
||||
|
||||
impl FrontendManager {
|
||||
pub fn new(receiver: Receiver<ActorMessage>) -> Self {
|
||||
Self { receiver }
|
||||
}
|
||||
|
||||
async fn handle_ws_connection(stream: TcpStream, addr: SocketAddr) -> Result<(), BoxError> {
|
||||
let websocket = accept_async(stream).await?;
|
||||
info!("Received WebSocket connection <{:?}>", addr);
|
||||
|
||||
let (_, ws_in) = websocket.split();
|
||||
|
||||
let on_received = ws_in.try_for_each(move |msg| {
|
||||
info!(
|
||||
"Received a message from {:?}: {}",
|
||||
addr,
|
||||
msg.to_text().unwrap()
|
||||
);
|
||||
|
||||
futures_util::future::ok(())
|
||||
});
|
||||
|
||||
tokio::spawn(on_received);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn websocket() -> Result<(), BoxError> {
|
||||
let server = TcpListener::bind("127.0.0.1:3012").await?;
|
||||
|
||||
while let Ok((stream, addr)) = server.accept().await {
|
||||
tokio::spawn(FrontendManager::handle_ws_connection(stream, addr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_message(&mut self, message: ActorMessage) -> Result<(), BoxError> {
|
||||
match message.message {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(message
|
||||
.respond_to
|
||||
.send((None, None))
|
||||
.map_err(|_| BoxError::from("Could not send message."))?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FrontendManagerHandle {
|
||||
sender: Sender<ActorMessage>,
|
||||
}
|
||||
|
||||
impl FrontendManagerHandle {
|
||||
// async fn run_frontend_manager(mut manager: FrontendManager) {
|
||||
// info!("Frontend handler ready");
|
||||
//
|
||||
// while let Some(msg) = manager.receiver.recv().await {
|
||||
// manager.handle_message(msg).await.unwrap();
|
||||
// }
|
||||
// }
|
||||
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = channel(1);
|
||||
|
||||
let _frontend = FrontendManager::new(receiver);
|
||||
|
||||
tokio::spawn(FrontendManager::websocket());
|
||||
// tokio::spawn(FrontendManagerHandle::run_frontend_manager(frontend));
|
||||
|
||||
Self { sender }
|
||||
}
|
||||
}
|
100
src/main.rs
Normal file
100
src/main.rs
Normal file
@ -0,0 +1,100 @@
|
||||
#![feature(drain_filter)]
|
||||
#![feature(bool_to_option)]
|
||||
|
||||
use std::env;
|
||||
|
||||
use fern::colors::{Color, ColoredLevelConfig};
|
||||
use log::error;
|
||||
use log::LevelFilter::Info;
|
||||
use tokio::time::Duration;
|
||||
|
||||
use crate::bot::Rustico;
|
||||
use crate::connectors::ExchangeDetails;
|
||||
use crate::currency::{Symbol, SymbolPair};
|
||||
|
||||
mod bot;
|
||||
mod connectors;
|
||||
mod currency;
|
||||
mod events;
|
||||
mod frontend;
|
||||
mod managers;
|
||||
mod models;
|
||||
mod strategy;
|
||||
mod ticker;
|
||||
mod tests;
|
||||
mod sounds;
|
||||
|
||||
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), BoxError> {
|
||||
if let Err(e) = setup_logger() {
|
||||
error!("Could not setup logger: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
if let Err(e) = dotenv::dotenv() {
|
||||
error!("Could not open .env file: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
let api_key = env::vars()
|
||||
.find(|(k, _v)| k == "API_KEY")
|
||||
.map(|(_k, v)| v)
|
||||
.ok_or("API_KEY not set!")?;
|
||||
let api_secret = env::vars()
|
||||
.find(|(k, _v)| k == "API_SECRET")
|
||||
.map(|(_k, v)| v)
|
||||
.ok_or("API_SECRET not set!")?;
|
||||
|
||||
let bitfinex = ExchangeDetails::Bitfinex {
|
||||
api_key,
|
||||
api_secret,
|
||||
};
|
||||
|
||||
let pairs = vec![
|
||||
SymbolPair::new(Symbol::BTC, Symbol::USD),
|
||||
SymbolPair::new(Symbol::XMR, Symbol::USD),
|
||||
SymbolPair::new(Symbol::ETH, Symbol::USD),
|
||||
SymbolPair::new(Symbol::DERIV_ADA, Symbol::DERIV_USDT),
|
||||
SymbolPair::new(Symbol::DERIV_POLKADOT, Symbol::DERIV_USDT),
|
||||
SymbolPair::new(Symbol::DERIV_BTC, Symbol::DERIV_USDT),
|
||||
SymbolPair::new(Symbol::DERIV_ETH, Symbol::DERIV_USDT),
|
||||
SymbolPair::new(Symbol::DERIV_TESTBTC, Symbol::DERIV_TESTUSDT),
|
||||
];
|
||||
|
||||
let mut bot = Rustico::new(
|
||||
vec![bitfinex],
|
||||
pairs,
|
||||
Duration::new(10, 0),
|
||||
);
|
||||
|
||||
Ok(bot.start_loop().await?)
|
||||
}
|
||||
|
||||
fn setup_logger() -> Result<(), fern::InitError> {
|
||||
let colors = ColoredLevelConfig::new()
|
||||
.info(Color::Green)
|
||||
.error(Color::Red)
|
||||
.trace(Color::Blue)
|
||||
.debug(Color::Cyan)
|
||||
.warn(Color::Yellow);
|
||||
|
||||
fern::Dispatch::new()
|
||||
.format(move |out, message, record| {
|
||||
out.finish(format_args!(
|
||||
"{} | [{}][{}] | {}",
|
||||
chrono::Local::now().format("[%d/%m/%Y][%H:%M:%S]"),
|
||||
record.target().strip_prefix("rustico::").unwrap_or("rustico"),
|
||||
colors.color(record.level()),
|
||||
message
|
||||
))
|
||||
})
|
||||
.level(Info)
|
||||
.filter(|metadata| metadata.target().contains("rustico"))
|
||||
.chain(std::io::stdout())
|
||||
.chain(fern::log_file("rustico.log")?)
|
||||
.apply()?;
|
||||
|
||||
Ok(())
|
||||
}
|
769
src/managers.rs
Normal file
769
src/managers.rs
Normal file
@ -0,0 +1,769 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Neg;
|
||||
|
||||
use futures_util::stream::FuturesUnordered;
|
||||
use futures_util::StreamExt;
|
||||
use log::{debug, error, info, trace};
|
||||
use merge::Merge;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::mpsc::channel;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::Duration;
|
||||
|
||||
use crate::BoxError;
|
||||
use crate::connectors::{Client, ExchangeDetails};
|
||||
use crate::currency::SymbolPair;
|
||||
use crate::events::{ActionMessage, ActorMessage, Event};
|
||||
use crate::models::{ActiveOrder, OrderBook, OrderForm, OrderKind, OrderMetadata, Position, PriceTicker};
|
||||
use crate::sounds::{MARKET_ORDER_PLACED_PATH, play_sound};
|
||||
use crate::strategy::{MarketEnforce, PositionStrategy, TrailingStop};
|
||||
|
||||
pub type OptionUpdate = (Option<Vec<Event>>, Option<Vec<ActionMessage>>);
|
||||
|
||||
/******************
|
||||
* PRICES
|
||||
******************/
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PriceManager {
|
||||
receiver: Receiver<ActorMessage>,
|
||||
pair: SymbolPair,
|
||||
prices: Vec<PriceEntry>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl PriceManager {
|
||||
pub fn new(receiver: Receiver<ActorMessage>, pair: SymbolPair, client: Client) -> Self {
|
||||
PriceManager {
|
||||
receiver,
|
||||
pair,
|
||||
prices: Vec::new(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_message(&mut self, message: ActorMessage) -> Result<(), BoxError> {
|
||||
if let ActionMessage::Update { tick } = message.message {
|
||||
let a = self.update(tick).await?;
|
||||
self.add_entry(a);
|
||||
}
|
||||
|
||||
Ok(message
|
||||
.respond_to
|
||||
.send((None, None))
|
||||
.map_err(|_| BoxError::from("Could not send message."))?)
|
||||
}
|
||||
|
||||
pub fn add_entry(&mut self, entry: PriceEntry) {
|
||||
self.prices.push(entry);
|
||||
}
|
||||
|
||||
pub async fn update(&mut self, tick: u64) -> Result<PriceEntry, BoxError> {
|
||||
let current_prices = self.client.current_prices(&self.pair).await?.into();
|
||||
|
||||
Ok(PriceEntry::new(
|
||||
tick,
|
||||
current_prices,
|
||||
self.pair.clone(),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PriceManagerHandle {
|
||||
sender: Sender<ActorMessage>,
|
||||
}
|
||||
|
||||
impl PriceManagerHandle {
|
||||
async fn run_price_manager(mut manager: PriceManager) {
|
||||
while let Some(msg) = manager.receiver.recv().await {
|
||||
manager.handle_message(msg).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(pair: SymbolPair, client: Client) -> Self {
|
||||
let (sender, receiver) = channel(1);
|
||||
|
||||
let price_manager = PriceManager::new(receiver, pair, client);
|
||||
tokio::spawn(PriceManagerHandle::run_price_manager(price_manager));
|
||||
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub async fn update(&mut self, tick: u64) -> Result<OptionUpdate, BoxError> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
|
||||
self.sender
|
||||
.send(ActorMessage {
|
||||
message: ActionMessage::Update { tick },
|
||||
respond_to: send,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(recv.await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PriceEntry {
|
||||
tick: u64,
|
||||
pair: SymbolPair,
|
||||
price: PriceTicker,
|
||||
events: Option<Vec<Event>>,
|
||||
}
|
||||
|
||||
impl PriceEntry {
|
||||
pub fn new(
|
||||
tick: u64,
|
||||
price: PriceTicker,
|
||||
pair: SymbolPair,
|
||||
events: Option<Vec<Event>>,
|
||||
) -> Self {
|
||||
PriceEntry {
|
||||
tick,
|
||||
pair,
|
||||
price,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&self) -> u64 {
|
||||
self.tick
|
||||
}
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
pub fn price(&self) -> PriceTicker {
|
||||
self.price
|
||||
}
|
||||
pub fn events(&self) -> &Option<Vec<Event>> {
|
||||
&self.events
|
||||
}
|
||||
}
|
||||
|
||||
/******************
|
||||
* POSITIONS
|
||||
******************/
|
||||
|
||||
pub struct PositionManagerHandle {
|
||||
sender: Sender<ActorMessage>,
|
||||
}
|
||||
|
||||
impl PositionManagerHandle {
|
||||
async fn run_position_manager(mut manager: PositionManager) {
|
||||
while let Some(msg) = manager.receiver.recv().await {
|
||||
manager.handle_message(msg).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(pair: SymbolPair, client: Client, strategy: Box<dyn PositionStrategy>) -> Self {
|
||||
let (sender, receiver) = channel(1);
|
||||
|
||||
let manager = PositionManager::new(receiver, pair, client, strategy);
|
||||
|
||||
tokio::spawn(PositionManagerHandle::run_position_manager(manager));
|
||||
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub async fn update(&mut self, tick: u64) -> Result<OptionUpdate, BoxError> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
|
||||
self.sender
|
||||
.send(ActorMessage {
|
||||
message: ActionMessage::Update { tick },
|
||||
respond_to: send,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = recv.await?;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PositionManager {
|
||||
receiver: Receiver<ActorMessage>,
|
||||
current_tick: u64,
|
||||
pair: SymbolPair,
|
||||
positions_history: HashMap<u64, Position>,
|
||||
active_position: Option<Position>,
|
||||
client: Client,
|
||||
strategy: Box<dyn PositionStrategy>,
|
||||
}
|
||||
|
||||
impl PositionManager {
|
||||
pub fn new(
|
||||
receiver: Receiver<ActorMessage>,
|
||||
pair: SymbolPair,
|
||||
client: Client,
|
||||
strategy: Box<dyn PositionStrategy>,
|
||||
) -> Self {
|
||||
PositionManager {
|
||||
receiver,
|
||||
current_tick: 0,
|
||||
pair,
|
||||
positions_history: HashMap::new(),
|
||||
active_position: None,
|
||||
client,
|
||||
strategy,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_tick(&self) -> u64 {
|
||||
self.current_tick
|
||||
}
|
||||
|
||||
pub async fn handle_message(&mut self, msg: ActorMessage) -> Result<(), BoxError> {
|
||||
let (events, messages) = match msg.message {
|
||||
ActionMessage::Update { tick } => self.update(tick).await?,
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
Ok(msg
|
||||
.respond_to
|
||||
.send((events, messages))
|
||||
.map_err(|_| BoxError::from("Could not send message."))?)
|
||||
}
|
||||
|
||||
pub async fn update(&mut self, tick: u64) -> Result<OptionUpdate, BoxError> {
|
||||
trace!("\t[PositionManager] Updating {}", self.pair);
|
||||
|
||||
self.current_tick = tick;
|
||||
let (fees, opt_active_positions) = tokio::join!(self.client.trading_fees(),self.client.active_positions(&self.pair));
|
||||
let (fees, opt_active_positions) = (fees?, opt_active_positions?);
|
||||
|
||||
// we assume there is only ONE active position per pair
|
||||
match opt_active_positions {
|
||||
// no open positions, no events and no messages returned
|
||||
None => return Ok((None, None)),
|
||||
|
||||
Some(positions) => {
|
||||
// checking if there are positions open for our pair
|
||||
match positions.into_iter().find(|x| x.pair() == &self.pair) {
|
||||
// no open positions for our pair, setting active position to none
|
||||
None => {
|
||||
self.active_position = None;
|
||||
return Ok((None, None));
|
||||
}
|
||||
|
||||
// applying strategy to open position and saving into struct
|
||||
Some(position) => {
|
||||
let mut events = None;
|
||||
let mut messages = None;
|
||||
|
||||
let (pos_on_tick, events_on_tick, messages_on_tick) = self
|
||||
.strategy
|
||||
.on_tick(position, self.current_tick(), &self.positions_history, &fees);
|
||||
|
||||
let (pos_post_tick, events_post_tick, messages_post_tick) = self
|
||||
.strategy
|
||||
.post_tick(pos_on_tick, self.current_tick(), &self.positions_history, &fees);
|
||||
|
||||
events.merge(events_on_tick);
|
||||
events.merge(events_post_tick);
|
||||
|
||||
messages.merge(messages_on_tick);
|
||||
messages.merge(messages_post_tick);
|
||||
|
||||
self.positions_history
|
||||
.insert(self.current_tick(), pos_post_tick.clone());
|
||||
self.active_position = Some(pos_post_tick);
|
||||
|
||||
return Ok((events, messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/******************
|
||||
* ORDERS
|
||||
******************/
|
||||
|
||||
pub struct OrderManagerHandle {
|
||||
sender: Sender<ActorMessage>,
|
||||
}
|
||||
|
||||
impl OrderManagerHandle {
|
||||
const SLEEP_DURATION: u64 = 5;
|
||||
|
||||
async fn run_order_manager(mut manager: OrderManager) {
|
||||
let mut sleep =
|
||||
tokio::time::interval(Duration::from_secs(OrderManagerHandle::SLEEP_DURATION));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
opt_msg = manager.receiver.recv() => {
|
||||
if let Some(msg) = opt_msg {
|
||||
manager.handle_message(msg).await.unwrap()
|
||||
}
|
||||
},
|
||||
_ = sleep.tick() => {
|
||||
manager.update().await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(pair: SymbolPair, client: Client) -> Self {
|
||||
let (sender, receiver) = channel(1);
|
||||
|
||||
let manager = OrderManager::new(receiver, pair, client);
|
||||
|
||||
tokio::spawn(OrderManagerHandle::run_order_manager(manager));
|
||||
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub async fn close_position(&mut self, position_id: u64) -> Result<OptionUpdate, BoxError> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
|
||||
self.sender
|
||||
.send(ActorMessage {
|
||||
message: ActionMessage::ClosePosition { position_id },
|
||||
respond_to: send,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(recv.await?)
|
||||
}
|
||||
|
||||
pub async fn close_position_orders(
|
||||
&mut self,
|
||||
position_id: u64,
|
||||
) -> Result<OptionUpdate, BoxError> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
|
||||
self.sender
|
||||
.send(ActorMessage {
|
||||
message: ActionMessage::ClosePositionOrders { position_id },
|
||||
respond_to: send,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(recv.await?)
|
||||
}
|
||||
|
||||
pub async fn submit_order(&mut self, order_form: OrderForm) -> Result<OptionUpdate, BoxError> {
|
||||
let (send, recv) = oneshot::channel();
|
||||
|
||||
self.sender
|
||||
.send(ActorMessage {
|
||||
message: ActionMessage::SubmitOrder { order: order_form },
|
||||
respond_to: send,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(recv.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OrderManager {
|
||||
receiver: Receiver<ActorMessage>,
|
||||
orders_map: HashMap<u64, HashSet<ActiveOrder>>,
|
||||
pair: SymbolPair,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl OrderManager {
|
||||
pub fn new(
|
||||
receiver: Receiver<ActorMessage>,
|
||||
pair: SymbolPair,
|
||||
client: Client,
|
||||
) -> Self {
|
||||
OrderManager {
|
||||
receiver,
|
||||
pair,
|
||||
client,
|
||||
orders_map: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* PRIVATE METHODS
|
||||
*/
|
||||
|
||||
fn add_to_orders_map(&mut self, position_id: u64, order: ActiveOrder) -> bool {
|
||||
self.orders_map
|
||||
.entry(position_id)
|
||||
.or_default()
|
||||
.insert(order)
|
||||
}
|
||||
|
||||
fn orders_from_position_id(&self, position_id: u64) -> Option<&HashSet<ActiveOrder>> {
|
||||
self.orders_map.get(&position_id)
|
||||
}
|
||||
|
||||
fn all_tracked_orders(&self) -> Option<Vec<ActiveOrder>> {
|
||||
let orders: Vec<_> = self.orders_map.values().flat_map(|x| x.clone()).collect();
|
||||
|
||||
(!orders.is_empty()).then_some(orders)
|
||||
}
|
||||
|
||||
async fn update_orders_map_from_remote(&mut self) -> Result<(), BoxError> {
|
||||
let (res_remote_orders, res_remote_positions) = tokio::join!(self.client.active_orders(&self.pair),
|
||||
self.client.active_positions(&self.pair));
|
||||
let (remote_orders, remote_positions) = (res_remote_orders?, res_remote_positions?);
|
||||
|
||||
match remote_positions {
|
||||
// no positions open, clear internal mapping
|
||||
None => { self.orders_map.clear(); }
|
||||
Some(positions) => {
|
||||
// retain only positions that are open remotely as well
|
||||
self.orders_map.retain(|local_id, _| positions.iter().any(|r| r.id() == *local_id));
|
||||
|
||||
for position in positions {
|
||||
// mapping tracked orders to their ids
|
||||
let tracked_orders: Vec<_> = self.orders_from_position_id(position.id())
|
||||
.iter()
|
||||
.flat_map(|x| x
|
||||
.iter()
|
||||
.map(|x| x.id()))
|
||||
.collect();
|
||||
|
||||
// adding remote order that are not in the internal mapping
|
||||
for remote_order in remote_orders.iter().filter(|x| !tracked_orders.contains(&x.id())) {
|
||||
// the only check to bind an active order to an open position,
|
||||
// is to check for their amount which should be identical
|
||||
if (remote_order.order_form().amount().abs() - position.amount().abs()).abs() < 0.0001 {
|
||||
trace!("Adding order {} to internal mapping from remote.", remote_order.id());
|
||||
self.add_to_orders_map(position.id(), remote_order.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// removing local orders that are not in remote
|
||||
for local_orders in self.orders_map.values_mut() {
|
||||
local_orders.retain(|l| remote_orders.iter().any(|r| r.id() == l.id()));
|
||||
}
|
||||
|
||||
// clean-up empty positions in local mapping
|
||||
let empty_positions_id: Vec<_> = self.orders_map
|
||||
.iter()
|
||||
.filter(|(_, orders)| orders.is_empty())
|
||||
.map(|(&position, _)| position)
|
||||
.collect();
|
||||
|
||||
for position_id in empty_positions_id {
|
||||
self.orders_map.remove(&position_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/*
|
||||
* PUBLIC METHODS
|
||||
*/
|
||||
|
||||
pub async fn handle_message(&mut self, msg: ActorMessage) -> Result<(), BoxError> {
|
||||
let (events, messages) = match msg.message {
|
||||
ActionMessage::Update { .. } => self.update().await?,
|
||||
ActionMessage::ClosePosition { position_id } => {
|
||||
self.close_position(position_id).await?
|
||||
}
|
||||
ActionMessage::ClosePositionOrders { position_id } => {
|
||||
self.close_position_orders(position_id).await?
|
||||
}
|
||||
ActionMessage::SubmitOrder { order } => self.submit_order(&order).await?,
|
||||
};
|
||||
|
||||
Ok(msg
|
||||
.respond_to
|
||||
.send((events, messages))
|
||||
.map_err(|_| BoxError::from("Could not send message."))?)
|
||||
}
|
||||
|
||||
pub async fn close_position_orders(&self, position_id: u64) -> Result<OptionUpdate, BoxError> {
|
||||
info!("Closing outstanding orders for position #{}", position_id);
|
||||
|
||||
if let Some(position_orders) = self.orders_map.get(&position_id) {
|
||||
for order in position_orders {
|
||||
match self.client.cancel_order(order).await {
|
||||
Ok(_) => info!("Order #{} closed successfully.", order.id()),
|
||||
Err(e) => error!("Could not close order #{}: {}", order.id(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: return valid messages and events!
|
||||
Ok((None, None))
|
||||
}
|
||||
|
||||
pub async fn submit_order(&mut self, order_form: &OrderForm) -> Result<OptionUpdate, BoxError> {
|
||||
info!("Submitting order: {}", order_form.kind());
|
||||
|
||||
// adding strategy to order, if present in the metadata
|
||||
let active_order = {
|
||||
if let Some(metadata) = order_form.metadata() {
|
||||
// TODO: this seems extremely dirty. Double check!
|
||||
self.client.submit_order(order_form).await?.with_strategy(metadata.cloned_strategy())
|
||||
} else {
|
||||
self.client.submit_order(order_form).await?
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(metadata) = order_form.metadata() {
|
||||
if let Some(position_id) = metadata.position_id() {
|
||||
debug!("Adding order to tracked orders.");
|
||||
|
||||
if !self.add_to_orders_map(position_id, active_order) {
|
||||
error!("Failed while adding order to internal mapping.");
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// play sound if Market order is placed
|
||||
if let OrderKind::Market = order_form.kind() {
|
||||
play_sound(MARKET_ORDER_PLACED_PATH);
|
||||
}
|
||||
|
||||
// TODO: return valid messages and events!111!!!1!
|
||||
Ok((None, None))
|
||||
}
|
||||
|
||||
pub async fn close_position(&mut self, position_id: u64) -> Result<OptionUpdate, BoxError> {
|
||||
info!("Closing position #{}", position_id);
|
||||
|
||||
debug!("Retrieving open orders, positions and current prices...");
|
||||
let (res_open_orders, res_order_book, res_open_positions) = tokio::join!(
|
||||
self.client.active_orders(&self.pair),
|
||||
self.client.order_book(&self.pair),
|
||||
self.client.active_positions(&self.pair)
|
||||
);
|
||||
|
||||
let (open_orders, order_book, open_positions) =
|
||||
(res_open_orders?, res_order_book?, res_open_positions?);
|
||||
|
||||
// if there are open positions
|
||||
if let Some(open_positions) = open_positions {
|
||||
// if we find an open position with the ID we are looking for
|
||||
if let Some(position) = open_positions.into_iter().find(|x| x.id() == position_id) {
|
||||
let opt_position_order = open_orders
|
||||
.iter()
|
||||
// avoid using direct equality, using error margin instead
|
||||
.find(|x| {
|
||||
(x.order_form().amount().neg() - position.amount()).abs() < 0.0000001
|
||||
});
|
||||
|
||||
// checking if the position has an open order.
|
||||
// If so, don't do anything since the order is taken care of
|
||||
// in the update phase.
|
||||
// If no order is open, send an undercut limit order at the best current price.
|
||||
if opt_position_order.is_none() {
|
||||
// No open order, undercutting best price with limit order
|
||||
let closing_price = self.best_closing_price(&position, &order_book);
|
||||
|
||||
let order_form = OrderForm::new(
|
||||
self.pair.clone(),
|
||||
OrderKind::Limit {
|
||||
price: closing_price,
|
||||
},
|
||||
position.platform(),
|
||||
position.amount().neg(),
|
||||
)
|
||||
.with_leverage(Some(position.leverage()))
|
||||
.with_metadata(Some(OrderMetadata::new()
|
||||
.with_strategy(Some(Box::new(MarketEnforce::default())))
|
||||
.with_position_id(Some(position.id())))
|
||||
);
|
||||
|
||||
// submitting order
|
||||
if let Err(e) = self.submit_order(&order_form).await {
|
||||
error!(
|
||||
"Could not submit {} to close position #{}: {}",
|
||||
order_form.kind(),
|
||||
position.id(),
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((None, None))
|
||||
}
|
||||
|
||||
pub async fn update(&mut self) -> Result<OptionUpdate, BoxError> {
|
||||
debug!("\t[OrderManager] Updating {}", self.pair);
|
||||
|
||||
// updating internal orders' mapping from remote
|
||||
self.update_orders_map_from_remote().await?;
|
||||
|
||||
// calling strategies for the orders and collecting resulting messages
|
||||
let _orders_messages: HashMap<&ActiveOrder, Vec<ActionMessage>> = HashMap::new();
|
||||
|
||||
if let Some(tracked_orders) = self.all_tracked_orders() {
|
||||
// since there are open orders, retrieve order book
|
||||
let order_book = self.client.order_book(&self.pair).await?;
|
||||
|
||||
for active_order in tracked_orders.iter().filter(|x| x.strategy().is_some()) {
|
||||
let strategy = active_order.strategy().as_ref().unwrap();
|
||||
|
||||
trace!(
|
||||
"Found open order with \"{}\" strategy.",
|
||||
strategy.name()
|
||||
);
|
||||
|
||||
// executing the order's strategy and collecting its messages, if any
|
||||
let (_, strat_messages) = strategy.on_open_order(&active_order, &order_book)?;
|
||||
|
||||
if let Some(messages) = strat_messages {
|
||||
for m in messages {
|
||||
match m {
|
||||
ActionMessage::SubmitOrder { order: order_form } => {
|
||||
info!("Closing open order...");
|
||||
info!("\tCancelling open order #{}", &active_order.id());
|
||||
self.client.cancel_order(&active_order).await?;
|
||||
|
||||
info!("\tSubmitting {}...", order_form.kind());
|
||||
self.submit_order(&order_form).await?;
|
||||
info!("Done!");
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"Received unsupported message from order strategy. Unimplemented."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((None, None))
|
||||
}
|
||||
|
||||
|
||||
pub fn best_closing_price(&self, position: &Position, order_book: &OrderBook) -> f64 {
|
||||
let ask = order_book.lowest_ask();
|
||||
let bid = order_book.highest_bid();
|
||||
let avg = (bid + ask) / 2.0;
|
||||
let delta = (ask - bid) / 10.0;
|
||||
|
||||
let closing_price = {
|
||||
if position.is_short() {
|
||||
bid - delta
|
||||
} else {
|
||||
ask + delta
|
||||
}
|
||||
};
|
||||
|
||||
if avg > 9999.0 {
|
||||
if position.is_short() {
|
||||
closing_price.ceil()
|
||||
} else {
|
||||
closing_price.floor()
|
||||
}
|
||||
} else {
|
||||
closing_price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PairManager {
|
||||
pair: SymbolPair,
|
||||
price_manager: PriceManagerHandle,
|
||||
order_manager: OrderManagerHandle,
|
||||
position_manager: PositionManagerHandle,
|
||||
}
|
||||
|
||||
impl PairManager {
|
||||
pub fn new(pair: SymbolPair, client: Client) -> Self {
|
||||
Self {
|
||||
pair: pair.clone(),
|
||||
price_manager: PriceManagerHandle::new(pair.clone(), client.clone()),
|
||||
order_manager: OrderManagerHandle::new(
|
||||
pair.clone(),
|
||||
client.clone(),
|
||||
),
|
||||
position_manager: PositionManagerHandle::new(
|
||||
pair,
|
||||
client,
|
||||
Box::new(TrailingStop::default()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_managers(&mut self, tick: u64) -> Result<(), BoxError> {
|
||||
let mut events = None;
|
||||
let mut messages = None;
|
||||
|
||||
let (price_results, pos_results) = tokio::join!(
|
||||
self.price_manager.update(tick),
|
||||
self.position_manager.update(tick),
|
||||
);
|
||||
|
||||
let (opt_price_events, opt_price_messages) = price_results?;
|
||||
let (opt_pos_events, opt_pos_messages) = pos_results?;
|
||||
|
||||
events.merge(opt_price_events);
|
||||
events.merge(opt_pos_events);
|
||||
|
||||
messages.merge(opt_price_messages);
|
||||
messages.merge(opt_pos_messages);
|
||||
|
||||
// TODO: to move into Handler?
|
||||
if let Some(messages) = messages {
|
||||
for m in messages {
|
||||
match m {
|
||||
ActionMessage::Update { .. } => {}
|
||||
ActionMessage::ClosePosition { position_id } => {
|
||||
self.order_manager.close_position(position_id).await?;
|
||||
}
|
||||
ActionMessage::SubmitOrder { order } => {
|
||||
self.order_manager.submit_order(order).await?;
|
||||
}
|
||||
ActionMessage::ClosePositionOrders { position_id } => {
|
||||
self.order_manager
|
||||
.close_position_orders(position_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExchangeManager {
|
||||
kind: ExchangeDetails,
|
||||
pair_managers: Vec<PairManager>,
|
||||
}
|
||||
|
||||
impl ExchangeManager {
|
||||
pub fn new(kind: &ExchangeDetails, pairs: &[SymbolPair]) -> Self {
|
||||
let client = Client::new(kind);
|
||||
let pair_managers = pairs
|
||||
.iter()
|
||||
.map(|x| PairManager::new(x.clone(), client.clone()))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
kind: kind.clone(),
|
||||
pair_managers,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_managers(&mut self, tick: u64) -> Result<(), BoxError> {
|
||||
let mut futures: FuturesUnordered<_> = self
|
||||
.pair_managers
|
||||
.iter_mut()
|
||||
.map(|x| x.update_managers(tick))
|
||||
.collect();
|
||||
|
||||
// execute the futures
|
||||
while futures.next().await.is_some() {}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
674
src/models.rs
Normal file
674
src/models.rs
Normal file
@ -0,0 +1,674 @@
|
||||
use std::fmt;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use dyn_clone::clone_box;
|
||||
|
||||
use crate::connectors::Exchange;
|
||||
use crate::currency::{Symbol, SymbolPair};
|
||||
use crate::strategy::OrderStrategy;
|
||||
|
||||
/***************
|
||||
* Prices
|
||||
***************/
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct PriceTicker {
|
||||
pub bid: f64,
|
||||
pub bid_size: f64,
|
||||
pub ask: f64,
|
||||
pub ask_size: f64,
|
||||
pub daily_change: f64,
|
||||
pub daily_change_perc: f64,
|
||||
pub last_price: f64,
|
||||
pub volume: f64,
|
||||
pub high: f64,
|
||||
pub low: f64,
|
||||
}
|
||||
|
||||
/***************
|
||||
* Orders
|
||||
***************/
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OrderBookEntry {
|
||||
Trading {
|
||||
price: f64,
|
||||
count: u64,
|
||||
amount: f64,
|
||||
},
|
||||
Funding {
|
||||
rate: f64,
|
||||
period: u64,
|
||||
count: u64,
|
||||
amount: f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OrderBook {
|
||||
pair: SymbolPair,
|
||||
entries: Vec<OrderBookEntry>,
|
||||
}
|
||||
|
||||
impl OrderBook {
|
||||
pub fn new(pair: SymbolPair) -> Self {
|
||||
OrderBook {
|
||||
pair,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_entries(mut self, entries: Vec<OrderBookEntry>) -> Self {
|
||||
self.entries = entries;
|
||||
self
|
||||
}
|
||||
|
||||
// TODO: distinguish between trading and funding
|
||||
pub fn bids(&self) -> Vec<&OrderBookEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|x| match x {
|
||||
OrderBookEntry::Trading { amount, .. } => amount > &0.0,
|
||||
OrderBookEntry::Funding { amount, .. } => amount < &0.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TODO: distinguish between trading and funding
|
||||
pub fn asks(&self) -> Vec<&OrderBookEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|x| match x {
|
||||
OrderBookEntry::Trading { amount, .. } => amount < &0.0,
|
||||
OrderBookEntry::Funding { amount, .. } => amount > &0.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn highest_bid(&self) -> f64 {
|
||||
self.bids()
|
||||
.iter()
|
||||
.map(|x| match x {
|
||||
OrderBookEntry::Trading { price, .. } => price,
|
||||
OrderBookEntry::Funding { rate, .. } => rate,
|
||||
})
|
||||
.fold(f64::NEG_INFINITY, |a, &b| a.max(b))
|
||||
}
|
||||
|
||||
pub fn lowest_ask(&self) -> f64 {
|
||||
self.asks()
|
||||
.iter()
|
||||
.map(|x| match x {
|
||||
OrderBookEntry::Trading { price, .. } => price,
|
||||
OrderBookEntry::Funding { rate, .. } => rate,
|
||||
})
|
||||
.fold(f64::INFINITY, |a, &b| a.min(b))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OrderFee {
|
||||
Maker(f64),
|
||||
Taker(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OrderDetails {
|
||||
exchange: Exchange,
|
||||
pair: SymbolPair,
|
||||
platform: TradingPlatform,
|
||||
kind: OrderKind,
|
||||
execution_timestamp: u64,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl OrderDetails {
|
||||
pub fn new(
|
||||
exchange: Exchange,
|
||||
id: u64,
|
||||
pair: SymbolPair,
|
||||
platform: TradingPlatform,
|
||||
kind: OrderKind,
|
||||
execution_timestamp: u64,
|
||||
) -> Self {
|
||||
OrderDetails {
|
||||
exchange,
|
||||
pair,
|
||||
platform,
|
||||
kind,
|
||||
execution_timestamp,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ActiveOrder {
|
||||
exchange: Exchange,
|
||||
id: u64,
|
||||
group_id: Option<u64>,
|
||||
client_id: Option<u64>,
|
||||
pair: SymbolPair,
|
||||
order_form: OrderForm,
|
||||
creation_timestamp: u64,
|
||||
update_timestamp: u64,
|
||||
strategy: Option<Box<dyn OrderStrategy>>,
|
||||
}
|
||||
|
||||
impl ActiveOrder {
|
||||
pub fn new(
|
||||
exchange: Exchange,
|
||||
id: u64,
|
||||
pair: SymbolPair,
|
||||
order_form: OrderForm,
|
||||
creation_timestamp: u64,
|
||||
update_timestamp: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
exchange,
|
||||
id,
|
||||
group_id: None,
|
||||
client_id: None,
|
||||
pair,
|
||||
order_form,
|
||||
creation_timestamp,
|
||||
update_timestamp,
|
||||
strategy: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_group_id(mut self, group_id: Option<u64>) -> Self {
|
||||
self.group_id = group_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_client_id(mut self, client_id: Option<u64>) -> Self {
|
||||
self.client_id = client_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_strategy(mut self, strategy: Option<Box<dyn OrderStrategy>>) -> Self {
|
||||
self.strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_leverage(mut self, leverage: Option<f64>) -> Self {
|
||||
self.order_form = self.order_form.with_leverage(leverage);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn exchange(&self) -> Exchange {
|
||||
self.exchange
|
||||
}
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
pub fn group_id(&self) -> Option<u64> {
|
||||
self.group_id
|
||||
}
|
||||
pub fn client_id(&self) -> Option<u64> {
|
||||
self.client_id
|
||||
}
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
pub fn order_form(&self) -> &OrderForm {
|
||||
&self.order_form
|
||||
}
|
||||
pub fn creation_timestamp(&self) -> u64 {
|
||||
self.creation_timestamp
|
||||
}
|
||||
pub fn update_timestamp(&self) -> u64 {
|
||||
self.update_timestamp
|
||||
}
|
||||
pub fn strategy(&self) -> &Option<Box<dyn OrderStrategy>> {
|
||||
&self.strategy
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ActiveOrder {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
state.write(&self.id.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ActiveOrder {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id && self.client_id == other.client_id && self.group_id == other.group_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ActiveOrder {}
|
||||
|
||||
impl Clone for ActiveOrder {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
exchange: self.exchange,
|
||||
id: self.id,
|
||||
group_id: self.group_id,
|
||||
client_id: self.client_id,
|
||||
pair: self.pair.clone(),
|
||||
order_form: self.order_form.clone(),
|
||||
creation_timestamp: self.creation_timestamp,
|
||||
update_timestamp: self.update_timestamp,
|
||||
strategy: self.strategy.as_ref().map(|x| clone_box(&**x)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum TradingPlatform {
|
||||
Exchange,
|
||||
Derivative,
|
||||
Funding,
|
||||
Margin,
|
||||
}
|
||||
|
||||
impl TradingPlatform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TradingPlatform::Exchange => "Exchange",
|
||||
TradingPlatform::Derivative => "Derivative",
|
||||
TradingPlatform::Funding => "Funding",
|
||||
TradingPlatform::Margin => "Margin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TradingPlatform {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum OrderKind {
|
||||
Limit { price: f64 },
|
||||
Market,
|
||||
Stop { price: f64 },
|
||||
StopLimit { stop_price: f64, limit_price: f64 },
|
||||
TrailingStop { distance: f64 },
|
||||
FillOrKill { price: f64 },
|
||||
ImmediateOrCancel { price: f64 },
|
||||
}
|
||||
|
||||
impl OrderKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OrderKind::Limit { .. } => "Limit",
|
||||
OrderKind::Market { .. } => "Market",
|
||||
OrderKind::Stop { .. } => "Stop",
|
||||
OrderKind::StopLimit { .. } => "Stop Limit",
|
||||
OrderKind::TrailingStop { .. } => "Trailing Stop",
|
||||
OrderKind::FillOrKill { .. } => "Fill or Kill",
|
||||
OrderKind::ImmediateOrCancel { .. } => "Immediate or Cancel",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for OrderKind {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
OrderKind::Limit { price } => {
|
||||
write!(f, "[{} | Price: {:0.5}]", self.as_str(), price, )
|
||||
}
|
||||
OrderKind::Market => {
|
||||
write!(f, "[{}]", self.as_str())
|
||||
}
|
||||
OrderKind::Stop { price } => {
|
||||
write!(f, "[{} | Price: {:0.5}]", self.as_str(), price, )
|
||||
}
|
||||
OrderKind::StopLimit { stop_price, limit_price } => {
|
||||
write!(
|
||||
f,
|
||||
"[{} | Stop: {:0.5}, Limit: {:0.5}]",
|
||||
self.as_str(),
|
||||
stop_price,
|
||||
limit_price
|
||||
)
|
||||
}
|
||||
OrderKind::TrailingStop { distance } => {
|
||||
write!(f, "[{} | Distance: {:0.5}]", self.as_str(), distance, )
|
||||
}
|
||||
OrderKind::FillOrKill { price } => {
|
||||
write!(f, "[{} | Price: {:0.5}]", self.as_str(), price, )
|
||||
}
|
||||
OrderKind::ImmediateOrCancel { price } => {
|
||||
write!(f, "[{} | Price: {:0.5}]", self.as_str(), price, )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrderForm {
|
||||
pair: SymbolPair,
|
||||
kind: OrderKind,
|
||||
platform: TradingPlatform,
|
||||
amount: f64,
|
||||
leverage: Option<f64>,
|
||||
metadata: Option<OrderMetadata>,
|
||||
}
|
||||
|
||||
impl OrderForm {
|
||||
pub fn new(
|
||||
pair: SymbolPair,
|
||||
order_kind: OrderKind,
|
||||
platform: TradingPlatform,
|
||||
amount: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
pair,
|
||||
kind: order_kind,
|
||||
platform,
|
||||
amount,
|
||||
leverage: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_leverage(mut self, leverage: Option<f64>) -> Self {
|
||||
self.leverage = leverage;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_metadata(mut self, metadata: Option<OrderMetadata>) -> Self {
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
pub fn kind(&self) -> OrderKind {
|
||||
self.kind
|
||||
}
|
||||
pub fn platform(&self) -> &TradingPlatform {
|
||||
&self.platform
|
||||
}
|
||||
pub fn amount(&self) -> f64 {
|
||||
self.amount
|
||||
}
|
||||
pub fn price(&self) -> Option<f64> {
|
||||
match self.kind {
|
||||
OrderKind::Limit { price, .. } => Some(price),
|
||||
OrderKind::Market { .. } => None,
|
||||
OrderKind::Stop { price, .. } => Some(price),
|
||||
OrderKind::StopLimit { stop_price: price, .. } => Some(price),
|
||||
OrderKind::TrailingStop { .. } => None,
|
||||
OrderKind::FillOrKill { price, .. } => Some(price),
|
||||
OrderKind::ImmediateOrCancel { price, .. } => Some(price),
|
||||
}
|
||||
}
|
||||
pub fn leverage(&self) -> Option<f64> {
|
||||
self.leverage
|
||||
}
|
||||
pub fn metadata(&self) -> &Option<OrderMetadata> {
|
||||
&self.metadata
|
||||
}
|
||||
pub fn is_long(&self) -> bool {
|
||||
self.amount.is_sign_positive()
|
||||
}
|
||||
pub fn is_short(&self) -> bool {
|
||||
self.amount.is_sign_negative()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OrderMetadata {
|
||||
position_id: Option<u64>,
|
||||
strategy: Option<Box<dyn OrderStrategy>>,
|
||||
}
|
||||
|
||||
impl Clone for OrderMetadata {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
position_id: self.position_id,
|
||||
strategy: self.strategy.as_ref().map(|x| clone_box(&**x)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OrderMetadata {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position_id: None,
|
||||
strategy: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_position_id(mut self, position_id: Option<u64>) -> Self {
|
||||
self.position_id = position_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_strategy(mut self, strategy: Option<Box<dyn OrderStrategy>>) -> Self {
|
||||
self.strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn position_id(&self) -> Option<u64> {
|
||||
self.position_id
|
||||
}
|
||||
pub fn cloned_strategy(&self) -> Option<Box<dyn OrderStrategy>> {
|
||||
match &self.strategy {
|
||||
None => { None }
|
||||
Some(strategy) => {
|
||||
Some(clone_box(&**strategy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrderMetadata {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/***************
|
||||
* Positions
|
||||
***************/
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Position {
|
||||
pair: SymbolPair,
|
||||
state: PositionState,
|
||||
profit_state: Option<PositionProfitState>,
|
||||
amount: f64,
|
||||
base_price: f64,
|
||||
pl: f64,
|
||||
pl_perc: f64,
|
||||
price_liq: f64,
|
||||
position_id: u64,
|
||||
creation_date: Option<u64>,
|
||||
creation_update: Option<u64>,
|
||||
platform: TradingPlatform,
|
||||
leverage: f64,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
pub fn new(
|
||||
pair: SymbolPair,
|
||||
state: PositionState,
|
||||
amount: f64,
|
||||
base_price: f64,
|
||||
pl: f64,
|
||||
pl_perc: f64,
|
||||
price_liq: f64,
|
||||
position_id: u64,
|
||||
platform: TradingPlatform,
|
||||
leverage: f64,
|
||||
) -> Self {
|
||||
Position {
|
||||
pair,
|
||||
state,
|
||||
amount,
|
||||
base_price,
|
||||
pl,
|
||||
pl_perc,
|
||||
price_liq,
|
||||
position_id,
|
||||
creation_date: None,
|
||||
creation_update: None,
|
||||
profit_state: None,
|
||||
platform,
|
||||
leverage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_creation_date(mut self, creation_date: Option<u64>) -> Self {
|
||||
self.creation_date = creation_date;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_creation_update(mut self, creation_update: Option<u64>) -> Self {
|
||||
self.creation_update = creation_update;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_profit_state(mut self, profit_state: Option<PositionProfitState>) -> Self {
|
||||
self.profit_state = profit_state;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn update_profit_loss(&mut self, best_offer: f64, fee_perc: f64) {
|
||||
let (base_price, delta) = {
|
||||
if self.is_short() {
|
||||
let base_price = self.base_price * (1.0 - fee_perc / 100.0);
|
||||
let delta = base_price - best_offer;
|
||||
|
||||
(base_price, delta)
|
||||
} else {
|
||||
let base_price = self.base_price * (1.0 + fee_perc / 100.0);
|
||||
let delta = best_offer - base_price;
|
||||
|
||||
(base_price, delta)
|
||||
}
|
||||
};
|
||||
|
||||
let profit_loss = delta * self.amount.abs();
|
||||
let profit_loss_percentage = delta / base_price * 100.0;
|
||||
|
||||
self.pl = profit_loss;
|
||||
self.pl_perc = profit_loss_percentage;
|
||||
}
|
||||
|
||||
pub fn with_profit_loss(mut self, profit_loss: f64) -> Self {
|
||||
self.pl = profit_loss;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pair(&self) -> &SymbolPair {
|
||||
&self.pair
|
||||
}
|
||||
pub fn state(&self) -> PositionState {
|
||||
self.state
|
||||
}
|
||||
pub fn amount(&self) -> f64 {
|
||||
self.amount
|
||||
}
|
||||
pub fn base_price(&self) -> f64 {
|
||||
self.base_price
|
||||
}
|
||||
pub fn pl(&self) -> f64 {
|
||||
self.pl
|
||||
}
|
||||
pub fn pl_perc(&self) -> f64 {
|
||||
self.pl_perc
|
||||
}
|
||||
pub fn price_liq(&self) -> f64 {
|
||||
self.price_liq
|
||||
}
|
||||
pub fn id(&self) -> u64 {
|
||||
self.position_id
|
||||
}
|
||||
pub fn profit_state(&self) -> Option<PositionProfitState> {
|
||||
self.profit_state
|
||||
}
|
||||
pub fn creation_date(&self) -> Option<u64> {
|
||||
self.creation_date
|
||||
}
|
||||
pub fn creation_update(&self) -> Option<u64> {
|
||||
self.creation_update
|
||||
}
|
||||
pub fn is_short(&self) -> bool {
|
||||
self.amount.is_sign_negative()
|
||||
}
|
||||
pub fn is_long(&self) -> bool {
|
||||
self.amount.is_sign_positive()
|
||||
}
|
||||
pub fn platform(&self) -> TradingPlatform {
|
||||
self.platform
|
||||
}
|
||||
pub fn leverage(&self) -> f64 {
|
||||
self.leverage
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Position {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
state.write(&self.id().to_le_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Position {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id() == other.id()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Position {}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
|
||||
pub enum PositionProfitState {
|
||||
Critical,
|
||||
Loss,
|
||||
BreakEven,
|
||||
MinimumProfit,
|
||||
Profit,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
|
||||
pub enum PositionState {
|
||||
Closed,
|
||||
Open,
|
||||
}
|
||||
|
||||
pub enum WalletKind {
|
||||
Exchange,
|
||||
Margin,
|
||||
Funding,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Trade {
|
||||
pub trade_id: u64,
|
||||
pub pair: SymbolPair,
|
||||
pub execution_timestamp: u64,
|
||||
pub price: f64,
|
||||
pub amount: f64,
|
||||
pub fee: OrderFee,
|
||||
pub fee_currency: Symbol,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TradingFees {
|
||||
Maker {
|
||||
platform: TradingPlatform,
|
||||
percentage: f64,
|
||||
},
|
||||
Taker {
|
||||
platform: TradingPlatform,
|
||||
percentage: f64,
|
||||
},
|
||||
}
|
19
src/sounds.rs
Normal file
19
src/sounds.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use ears::{AudioController, Sound};
|
||||
use log::error;
|
||||
|
||||
pub const MARKET_ORDER_PLACED_PATH: &str = "sounds/smas-smb3_goal.wav";
|
||||
pub const LOSS_TO_BREAK_EVEN_PATH: &str = "sounds/smw2_boing.wav";
|
||||
pub const MIN_PROFIT_SOUND_PATH: &str = "sounds/smw_1-up.wav";
|
||||
pub const GOOD_PROFIT_SOUND_PATH: &str = "sounds/smw_power-up.wav";
|
||||
|
||||
pub fn play_sound(sound_path: &'static str) {
|
||||
std::thread::spawn(move || {
|
||||
match Sound::new(sound_path) {
|
||||
Ok(mut sound) => {
|
||||
sound.play();
|
||||
while sound.is_playing() {}
|
||||
}
|
||||
Err(e) => { error!("Could not play {}: {}", sound_path, e); }
|
||||
}
|
||||
});
|
||||
}
|
449
src/strategy.rs
Normal file
449
src/strategy.rs
Normal file
@ -0,0 +1,449 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::ops::Neg;
|
||||
|
||||
use dyn_clone::DynClone;
|
||||
use log::info;
|
||||
|
||||
use crate::BoxError;
|
||||
use crate::events::{ActionMessage, Event, EventKind, EventMetadata};
|
||||
use crate::managers::OptionUpdate;
|
||||
use crate::models::{ActiveOrder, OrderBook, OrderForm, OrderKind, OrderMetadata, Position, PositionProfitState, TradingFees};
|
||||
use crate::models::PositionProfitState::{BreakEven, Critical, Loss, MinimumProfit, Profit};
|
||||
use crate::sounds::{GOOD_PROFIT_SOUND_PATH, LOSS_TO_BREAK_EVEN_PATH, MIN_PROFIT_SOUND_PATH, play_sound};
|
||||
|
||||
/***************
|
||||
* DEFINITIONS
|
||||
***************/
|
||||
|
||||
pub trait PositionStrategy: DynClone + Send + Sync {
|
||||
fn name(&self) -> String;
|
||||
fn on_tick(
|
||||
&mut self,
|
||||
position: Position,
|
||||
current_tick: u64,
|
||||
positions_history: &HashMap<u64, Position>,
|
||||
fees: &[TradingFees],
|
||||
) -> (Position, Option<Vec<Event>>, Option<Vec<ActionMessage>>);
|
||||
fn post_tick(
|
||||
&mut self,
|
||||
position: Position,
|
||||
current_tick: u64,
|
||||
positions_history: &HashMap<u64, Position>,
|
||||
fees: &[TradingFees],
|
||||
) -> (Position, Option<Vec<Event>>, Option<Vec<ActionMessage>>);
|
||||
}
|
||||
|
||||
impl Debug for dyn PositionStrategy {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait OrderStrategy: DynClone + Send + Sync {
|
||||
/// The name of the strategy, used for debugging purposes
|
||||
fn name(&self) -> String;
|
||||
/// This method is called when the OrderManager checks the open orders on a new tick.
|
||||
/// It should manage if some orders have to be closed or keep open.
|
||||
fn on_open_order(
|
||||
&self,
|
||||
order: &ActiveOrder,
|
||||
order_book: &OrderBook,
|
||||
) -> Result<OptionUpdate, BoxError>;
|
||||
// /// This method is called when the OrderManager is requested to close
|
||||
// /// a position that has an open order associated to it.
|
||||
// fn on_position_order(
|
||||
// &self,
|
||||
// order: &ActiveOrder,
|
||||
// open_position: &Position,
|
||||
// order_book: &OrderBook,
|
||||
// ) -> Result<OptionUpdate, BoxError>;
|
||||
}
|
||||
|
||||
impl Debug for dyn OrderStrategy {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
/***************
|
||||
* IMPLEMENTATIONS
|
||||
***************/
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrailingStop {
|
||||
// Position ID: stop percentage mapping
|
||||
stop_percentages: HashMap<u64, f64>,
|
||||
// Position ID: bool mapping. Represents when the strategy has asked the
|
||||
// order manager to set a stop loss order
|
||||
stop_loss_flags: HashMap<u64, bool>,
|
||||
// Position ID: bool mapping. Represents when the strategy has asked the
|
||||
// order manager to set a limit order to close the position as the stop percentage
|
||||
// has been surpassed
|
||||
trail_set_flags: HashMap<u64, bool>,
|
||||
capital_max_loss: f64,
|
||||
capital_min_profit: f64,
|
||||
capital_good_profit: f64,
|
||||
min_profit_trailing_delta: f64,
|
||||
good_profit_trailing_delta: f64,
|
||||
leverage: f64,
|
||||
min_profit_percentage: f64,
|
||||
good_profit_percentage: f64,
|
||||
max_loss_percentage: f64,
|
||||
}
|
||||
|
||||
|
||||
impl TrailingStop {
|
||||
fn play_sound_on_state(prev_position: &Position, current_position: &Position) {
|
||||
if prev_position.profit_state().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
if current_position.profit_state().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let prev_state = prev_position.profit_state().unwrap();
|
||||
let current_state = current_position.profit_state().unwrap();
|
||||
|
||||
// negative to positive
|
||||
if let Loss | Critical = prev_state {
|
||||
match current_state {
|
||||
PositionProfitState::BreakEven => { play_sound(LOSS_TO_BREAK_EVEN_PATH); }
|
||||
PositionProfitState::MinimumProfit => { play_sound(MIN_PROFIT_SOUND_PATH); }
|
||||
PositionProfitState::Profit => { play_sound(GOOD_PROFIT_SOUND_PATH); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let BreakEven = prev_state {
|
||||
match current_state {
|
||||
PositionProfitState::MinimumProfit => { play_sound(MIN_PROFIT_SOUND_PATH); }
|
||||
PositionProfitState::Profit => { play_sound(GOOD_PROFIT_SOUND_PATH); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_status(&self, position: &Position) {
|
||||
match self.stop_percentages.get(&position.id()) {
|
||||
None => {
|
||||
info!(
|
||||
"\tState: {:?} | PL: {:0.2}{} ({:0.2}%)",
|
||||
position.profit_state().unwrap(),
|
||||
position.pl(),
|
||||
position.pair().quote(),
|
||||
position.pl_perc()
|
||||
);
|
||||
}
|
||||
Some(stop_percentage) => {
|
||||
info!(
|
||||
"\tState: {:?} | PL: {:0.2}{} ({:0.2}%) | Stop: {:0.2}",
|
||||
position.profit_state().unwrap(),
|
||||
position.pl(),
|
||||
position.pair().quote(),
|
||||
position.pl_perc(),
|
||||
stop_percentage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_stop_percentage(&mut self, position: &Position) {
|
||||
if let Some(profit_state) = position.profit_state() {
|
||||
let profit_state_delta = match profit_state {
|
||||
PositionProfitState::MinimumProfit => self.min_profit_trailing_delta,
|
||||
PositionProfitState::Profit => self.good_profit_trailing_delta,
|
||||
_ => return
|
||||
};
|
||||
|
||||
let current_trailing_delta = position.pl_perc() - profit_state_delta;
|
||||
|
||||
match self.stop_percentages.get(&position.id()) {
|
||||
None => {
|
||||
self.stop_percentages
|
||||
.insert(position.id(), current_trailing_delta);
|
||||
}
|
||||
Some(existing_threshold) => {
|
||||
if existing_threshold < ¤t_trailing_delta {
|
||||
self.stop_percentages
|
||||
.insert(position.id(), current_trailing_delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TrailingStop {
|
||||
fn default() -> Self {
|
||||
let leverage = 15.0;
|
||||
|
||||
// in percentage
|
||||
let capital_min_profit = 8.5;
|
||||
let capital_max_loss = capital_min_profit * 1.9;
|
||||
let capital_good_profit = capital_min_profit * 2.0;
|
||||
|
||||
let weighted_min_profit = capital_min_profit / leverage;
|
||||
let weighted_good_profit = capital_good_profit / leverage;
|
||||
let weighted_max_loss = capital_max_loss / leverage;
|
||||
|
||||
let min_profit_trailing_delta = weighted_min_profit * 0.17;
|
||||
let good_profit_trailing_delta = weighted_good_profit * 0.08;
|
||||
|
||||
let min_profit_percentage = weighted_min_profit + min_profit_trailing_delta;
|
||||
let good_profit_percentage = weighted_good_profit + good_profit_trailing_delta;
|
||||
let max_loss_percentage = -weighted_max_loss;
|
||||
|
||||
TrailingStop {
|
||||
stop_percentages: Default::default(),
|
||||
stop_loss_flags: Default::default(),
|
||||
trail_set_flags: Default::default(),
|
||||
capital_max_loss,
|
||||
capital_min_profit,
|
||||
capital_good_profit,
|
||||
min_profit_trailing_delta,
|
||||
good_profit_trailing_delta,
|
||||
leverage,
|
||||
min_profit_percentage,
|
||||
good_profit_percentage,
|
||||
max_loss_percentage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PositionStrategy for TrailingStop {
|
||||
fn name(&self) -> String {
|
||||
"Trailing Stop".into()
|
||||
}
|
||||
|
||||
/// Sets the profit state of an open position
|
||||
fn on_tick(
|
||||
&mut self,
|
||||
position: Position,
|
||||
current_tick: u64,
|
||||
positions_history: &HashMap<u64, Position>,
|
||||
_: &[TradingFees],
|
||||
) -> (Position, Option<Vec<Event>>, Option<Vec<ActionMessage>>) {
|
||||
let pl_perc = position.pl_perc();
|
||||
|
||||
// setting the state of the position based on its profit/loss percentage
|
||||
let state = {
|
||||
if pl_perc > self.good_profit_percentage {
|
||||
Profit
|
||||
} else if (self.min_profit_percentage..self.good_profit_percentage).contains(&pl_perc) {
|
||||
MinimumProfit
|
||||
} else if (0.0..self.min_profit_percentage).contains(&pl_perc) {
|
||||
BreakEven
|
||||
} else if (self.max_loss_percentage..0.0).contains(&pl_perc) {
|
||||
Loss
|
||||
} else {
|
||||
Critical
|
||||
}
|
||||
};
|
||||
|
||||
let opt_prev_position = positions_history.get(&(current_tick - 1));
|
||||
let event_metadata = EventMetadata::new(Some(position.id()), None);
|
||||
let new_position = position.with_profit_state(Some(state));
|
||||
|
||||
// checking if there was a state change between the current position
|
||||
// and its last state
|
||||
match opt_prev_position {
|
||||
Some(prev) => {
|
||||
if prev.profit_state() == Some(state) {
|
||||
return (new_position, None, None);
|
||||
}
|
||||
|
||||
TrailingStop::play_sound_on_state(&prev, &new_position);
|
||||
}
|
||||
None => return (new_position, None, None),
|
||||
};
|
||||
|
||||
let event = match state {
|
||||
PositionProfitState::Critical => {
|
||||
Event::new(
|
||||
EventKind::ReachedMaxLoss,
|
||||
current_tick,
|
||||
)
|
||||
}
|
||||
PositionProfitState::Loss => {
|
||||
Event::new(
|
||||
EventKind::ReachedLoss,
|
||||
current_tick,
|
||||
)
|
||||
}
|
||||
PositionProfitState::BreakEven => {
|
||||
Event::new(
|
||||
EventKind::ReachedBreakEven,
|
||||
current_tick,
|
||||
)
|
||||
}
|
||||
PositionProfitState::MinimumProfit => {
|
||||
Event::new(
|
||||
EventKind::ReachedMinProfit,
|
||||
current_tick,
|
||||
)
|
||||
}
|
||||
PositionProfitState::Profit => {
|
||||
Event::new(
|
||||
EventKind::ReachedGoodProfit,
|
||||
current_tick,
|
||||
)
|
||||
}
|
||||
}.with_metadata(Some(event_metadata));
|
||||
|
||||
(new_position, Some(vec![event]), None)
|
||||
}
|
||||
|
||||
fn post_tick(
|
||||
&mut self,
|
||||
position: Position,
|
||||
_: u64,
|
||||
_: &HashMap<u64, Position>,
|
||||
fees: &[TradingFees],
|
||||
) -> (Position, Option<Vec<Event>>, Option<Vec<ActionMessage>>) {
|
||||
let taker_fee = fees
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
TradingFees::Taker {
|
||||
platform,
|
||||
percentage,
|
||||
} if platform == &position.platform() => Some(percentage),
|
||||
_ => None,
|
||||
})
|
||||
.next().map_or_else(|| 0.0, |&x| x);
|
||||
|
||||
// we need to consider possible slippage when executing the stop order
|
||||
let slippage_percentage = self.max_loss_percentage * 0.085;
|
||||
|
||||
// calculating the stop price based on short/long position
|
||||
let stop_loss_price = {
|
||||
if position.is_short() {
|
||||
position.base_price() * (1.0 - (self.max_loss_percentage - taker_fee - slippage_percentage) / 100.0)
|
||||
} else {
|
||||
position.base_price() * (1.0 + (self.max_loss_percentage - taker_fee - slippage_percentage) / 100.0)
|
||||
}
|
||||
};
|
||||
|
||||
let close_position_orders_msg = ActionMessage::ClosePositionOrders {
|
||||
position_id: position.id(),
|
||||
};
|
||||
let close_position_msg = ActionMessage::ClosePosition {
|
||||
position_id: position.id(),
|
||||
};
|
||||
let set_stop_loss_msg = ActionMessage::SubmitOrder {
|
||||
order: OrderForm::new(position.pair().clone(),
|
||||
OrderKind::Stop { price: stop_loss_price },
|
||||
position.platform(),
|
||||
position.amount().neg())
|
||||
.with_leverage(Some(self.leverage))
|
||||
.with_metadata(Some(OrderMetadata::new().with_position_id(Some(position.id()))))
|
||||
};
|
||||
let stop_loss_set = *self.stop_loss_flags.entry(position.id()).or_insert(false);
|
||||
|
||||
// if in loss, ask the order manager to set the stop limit order,
|
||||
// if not already set
|
||||
if let Some(PositionProfitState::Critical) | Some(PositionProfitState::Loss) = position.profit_state() {
|
||||
self.print_status(&position);
|
||||
|
||||
if !stop_loss_set {
|
||||
info!("In loss. Opening trailing stop order.");
|
||||
|
||||
self.stop_loss_flags.insert(position.id(), true);
|
||||
return (position, None, Some(vec![set_stop_loss_msg]));
|
||||
}
|
||||
return (position, None, None);
|
||||
}
|
||||
|
||||
// if we get here we are with a profit/loss ration > 0.0
|
||||
|
||||
let mut messages = vec![];
|
||||
|
||||
// if a stop loss order was previously set,
|
||||
// ask the order manager to remove the order first
|
||||
if stop_loss_set {
|
||||
info!("Removing stop loss order.");
|
||||
messages.push(close_position_orders_msg);
|
||||
self.stop_loss_flags.insert(position.id(), false);
|
||||
}
|
||||
|
||||
self.update_stop_percentage(&position);
|
||||
self.print_status(&position);
|
||||
|
||||
// let's check if we surpassed an existing stop percentage
|
||||
if let Some(existing_stop_percentage) = self.stop_percentages.get(&position.id()) {
|
||||
if &position.pl_perc() <= existing_stop_percentage {
|
||||
info!("Stop percentage surpassed. Closing position.");
|
||||
messages.push(close_position_msg);
|
||||
return (position, None, Some(messages));
|
||||
}
|
||||
}
|
||||
|
||||
(position, None, Some(messages))
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ORDER STRATEGIES
|
||||
*/
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MarketEnforce {
|
||||
// threshold (%) for which we trigger a market order
|
||||
// to close an open position
|
||||
threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for MarketEnforce {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threshold: 1.2 / 15.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OrderStrategy for MarketEnforce {
|
||||
fn name(&self) -> String {
|
||||
"Market Enforce".into()
|
||||
}
|
||||
|
||||
fn on_open_order(
|
||||
&self,
|
||||
order: &ActiveOrder,
|
||||
order_book: &OrderBook,
|
||||
) -> Result<OptionUpdate, BoxError> {
|
||||
let mut messages = vec![];
|
||||
|
||||
// long
|
||||
let offer_comparison = {
|
||||
if order.order_form().is_long() {
|
||||
order_book.highest_bid()
|
||||
} else {
|
||||
order_book.lowest_ask()
|
||||
}
|
||||
};
|
||||
|
||||
// if the best offer is higher than our threshold,
|
||||
// ask the manager to close the position with a market order
|
||||
let order_price = order
|
||||
.order_form()
|
||||
.price()
|
||||
.ok_or("The active order does not have a price!")?;
|
||||
let delta = (1.0 - (offer_comparison / order_price)).abs() * 100.0;
|
||||
|
||||
if delta > self.threshold {
|
||||
messages.push(ActionMessage::SubmitOrder {
|
||||
order: OrderForm::new(
|
||||
order.pair().clone(),
|
||||
OrderKind::Market,
|
||||
*order.order_form().platform(),
|
||||
order.order_form().amount(),
|
||||
)
|
||||
.with_leverage(order.order_form().leverage())
|
||||
.with_metadata(order.order_form().metadata().clone()),
|
||||
})
|
||||
}
|
||||
|
||||
Ok((None, (!messages.is_empty()).then_some(messages)))
|
||||
}
|
||||
}
|
89
src/tests.rs
Normal file
89
src/tests.rs
Normal file
@ -0,0 +1,89 @@
|
||||
#[cfg(test)]
|
||||
mod common {
|
||||
use crate::currency::{Symbol, SymbolPair};
|
||||
use crate::models::{Position, PositionState, TradingPlatform};
|
||||
use crate::models::PositionProfitState::Loss;
|
||||
|
||||
// TODO: generate other helper generator functions like the one below
|
||||
|
||||
// Generates two short positions with different profit/loss ratios. Both are position in "Loss".
|
||||
pub fn get_short_loss_positions(pair: SymbolPair) -> (Position, Position) {
|
||||
let almost_critical = Position::new(pair.clone(),
|
||||
PositionState::Open,
|
||||
-0.1,
|
||||
100.0,
|
||||
-2.0,
|
||||
-2.0,
|
||||
150.0,
|
||||
0,
|
||||
TradingPlatform::Margin,
|
||||
0.0)
|
||||
.with_profit_state(Some(Loss));
|
||||
let loss = Position::new(pair.clone(),
|
||||
PositionState::Open,
|
||||
-0.1,
|
||||
100.0,
|
||||
-1.0,
|
||||
-1.0,
|
||||
150.0,
|
||||
0,
|
||||
TradingPlatform::Margin,
|
||||
0.0)
|
||||
.with_profit_state(Some(Loss));
|
||||
|
||||
(almost_critical, loss)
|
||||
}
|
||||
|
||||
pub fn get_btcusd_pair() -> SymbolPair {
|
||||
SymbolPair::new(Symbol::BTC, Symbol::USD)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod positions {
|
||||
use crate::models::{Position, PositionState, TradingPlatform};
|
||||
use crate::models::PositionProfitState::Loss;
|
||||
use crate::tests::common::{get_btcusd_pair, get_short_loss_positions};
|
||||
|
||||
#[test]
|
||||
fn short_positions() {
|
||||
let pair = get_btcusd_pair();
|
||||
|
||||
let one = Position::new(pair.clone(),
|
||||
PositionState::Open,
|
||||
-0.1,
|
||||
100.0,
|
||||
-2.0,
|
||||
-2.0,
|
||||
150.0,
|
||||
0,
|
||||
TradingPlatform::Margin,
|
||||
0.0);
|
||||
|
||||
assert_eq!(one.pair(), &pair);
|
||||
assert_eq!(one.is_long(), false);
|
||||
assert_eq!(one.is_short(), true);
|
||||
assert_eq!(one.profit_state(), None);
|
||||
assert_eq!(one.platform(), TradingPlatform::Margin);
|
||||
assert_eq!(one.amount(), -0.1);
|
||||
assert_eq!(one.base_price(), 100.0);
|
||||
assert_eq!(one.pl(), -2.0);
|
||||
assert_eq!(one.pl_perc(), -2.0);
|
||||
assert_eq!(one.id(), 0);
|
||||
assert_eq!(one.leverage(), 0.0);
|
||||
assert_eq!(one.price_liq(), 150.0);
|
||||
assert_eq!(one.state(), PositionState::Open);
|
||||
assert!(one.price_liq() > one.base_price());
|
||||
|
||||
let (two, three) = get_short_loss_positions(pair);
|
||||
|
||||
assert_eq!(two.is_short(), true);
|
||||
assert_eq!(two.is_long(), false);
|
||||
assert_eq!(three.is_short(), true);
|
||||
assert_eq!(three.is_long(), false);
|
||||
assert_eq!(two.profit_state(), Some(Loss));
|
||||
assert_eq!(three.profit_state(), Some(Loss));
|
||||
|
||||
// TODO: add more test positions with and without profit states
|
||||
}
|
||||
}
|
25
src/ticker.rs
Normal file
25
src/ticker.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use tokio::time::Duration;
|
||||
|
||||
pub struct Ticker {
|
||||
duration: Duration,
|
||||
current_tick: u64,
|
||||
}
|
||||
|
||||
impl Ticker {
|
||||
pub fn new(duration: Duration) -> Self {
|
||||
Ticker {
|
||||
duration,
|
||||
current_tick: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inc(&mut self) {
|
||||
self.current_tick += 1
|
||||
}
|
||||
pub fn duration(&self) -> Duration {
|
||||
self.duration
|
||||
}
|
||||
pub fn current_tick(&self) -> u64 {
|
||||
self.current_tick
|
||||
}
|
||||
}
|
3
static/.gitignore
vendored
3
static/.gitignore
vendored
@ -1,3 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
|
123
strategy.py
123
strategy.py
@ -1,123 +0,0 @@
|
||||
from typing import List, Dict
|
||||
|
||||
import sympy.abc
|
||||
from bfxapi import Position
|
||||
from sympy import Point, solve
|
||||
|
||||
from bfxbot.models import Strategy, PositionState, SymbolStatus, Event, EventKind, EventMetadata, PositionWrapper, \
|
||||
TAKER_FEE
|
||||
from bfxbot.utils import net_pl_percentage
|
||||
|
||||
|
||||
class SquaredTrailingStop:
|
||||
def __init__(self, p_min: Point, p_max: Point):
|
||||
a = sympy.abc.a
|
||||
b = sympy.abc.b
|
||||
c = sympy.abc.c
|
||||
|
||||
self.p_min = p_min
|
||||
self.p_max = p_max
|
||||
|
||||
e1 = 2 * a * (p_max.x + b)
|
||||
e2 = a * (p_min.x + b) ** 2 + c - p_min.y
|
||||
e3 = a * (p_max.x + b) ** 2 + c - p_max.y
|
||||
|
||||
s = solve([e1, e2, e3])[0]
|
||||
|
||||
self.a, self.b, self.c = s[a], s[b], s[c]
|
||||
|
||||
def y(self, x):
|
||||
def inter_y(x):
|
||||
return self.a * (x + self.b) ** 2 + self.c
|
||||
|
||||
if x < self.p_min.x:
|
||||
return self.p_min.y
|
||||
elif x > self.p_max.x:
|
||||
return self.p_max.y
|
||||
else:
|
||||
return inter_y(x)
|
||||
|
||||
def profit(self, x):
|
||||
if x < self.p_min.x:
|
||||
return 0
|
||||
return x - self.y(x)
|
||||
|
||||
|
||||
class TrailingStopStrategy(Strategy):
|
||||
BREAK_EVEN_PERC = TAKER_FEE
|
||||
MIN_PROFIT_PERC = BREAK_EVEN_PERC + 0.3
|
||||
GOOD_PROFIT_PERC = MIN_PROFIT_PERC * 2.5
|
||||
MAX_LOSS_PERC = -4.0
|
||||
|
||||
TRAILING_STOP = SquaredTrailingStop(Point(MIN_PROFIT_PERC, MIN_PROFIT_PERC / 3 * 2), Point(GOOD_PROFIT_PERC, 0.1))
|
||||
|
||||
def __init__(self):
|
||||
# position_id : stop percentage
|
||||
self.stop_percentage: Dict[int, float] = {}
|
||||
|
||||
def position_on_new_tick(self, current_position: Position, ss: SymbolStatus) -> (PositionState, List[Event]):
|
||||
events = []
|
||||
|
||||
pl_perc = net_pl_percentage(current_position.profit_loss_percentage, TAKER_FEE)
|
||||
prev = ss.previous_pw(current_position.id)
|
||||
event_metadata = EventMetadata(position_id=current_position.id)
|
||||
|
||||
if pl_perc > self.GOOD_PROFIT_PERC:
|
||||
state = PositionState.PROFIT
|
||||
elif self.MIN_PROFIT_PERC <= pl_perc < self.GOOD_PROFIT_PERC:
|
||||
state = PositionState.MINIMUM_PROFIT
|
||||
elif 0.0 <= pl_perc < self.MIN_PROFIT_PERC:
|
||||
state = PositionState.BREAK_EVEN
|
||||
elif self.MAX_LOSS_PERC < pl_perc < 0.0:
|
||||
state = PositionState.LOSS
|
||||
else:
|
||||
events.append(Event(EventKind.CLOSE_POSITION, ss.current_tick, event_metadata))
|
||||
state = PositionState.CRITICAL
|
||||
|
||||
pw = PositionWrapper(current_position, state=state, net_profit_loss=current_position.profit_loss,
|
||||
net_profit_loss_percentage=pl_perc)
|
||||
|
||||
if not prev or prev.state() == state:
|
||||
return pw, events
|
||||
|
||||
if state == PositionState.PROFIT:
|
||||
events.append(Event(EventKind.REACHED_GOOD_PROFIT, ss.current_tick, event_metadata))
|
||||
elif state == PositionState.MINIMUM_PROFIT:
|
||||
events.append(Event(EventKind.REACHED_MIN_PROFIT, ss.current_tick, event_metadata))
|
||||
elif state == PositionState.BREAK_EVEN:
|
||||
events.append(Event(EventKind.REACHED_BREAK_EVEN, ss.current_tick, event_metadata))
|
||||
elif state == PositionState.LOSS:
|
||||
events.append(Event(EventKind.REACHED_LOSS, ss.current_tick, event_metadata))
|
||||
else:
|
||||
events.append(Event(EventKind.REACHED_MAX_LOSS, ss.current_tick, event_metadata))
|
||||
events.append(Event(EventKind.CLOSE_POSITION, ss.current_tick, event_metadata))
|
||||
|
||||
return pw, events
|
||||
|
||||
async def update_stop_percentage(self, pw: PositionWrapper, ss: SymbolStatus):
|
||||
current_pl_perc = pw.net_profit_loss_percentage()
|
||||
pid = pw.position.id
|
||||
event_metadata = EventMetadata(position_id=pw.position.id)
|
||||
|
||||
# if trailing stop not set for this position and state is not profit (we should not set it)
|
||||
if pid not in self.stop_percentage and pw.state() not in [PositionState.MINIMUM_PROFIT,
|
||||
PositionState.PROFIT]:
|
||||
return
|
||||
|
||||
# set stop percentage for first time only if in profit
|
||||
if pid not in self.stop_percentage:
|
||||
await ss.add_event(Event(EventKind.TRAILING_STOP_SET, ss.current_tick, event_metadata))
|
||||
|
||||
self.stop_percentage[pid] = current_pl_perc - self.TRAILING_STOP.y(current_pl_perc)
|
||||
return
|
||||
|
||||
# moving trailing stop
|
||||
if current_pl_perc - self.TRAILING_STOP.y(current_pl_perc) > self.stop_percentage[pid]:
|
||||
await ss.add_event(Event(EventKind.TRAILING_STOP_MOVED, ss.current_tick, event_metadata))
|
||||
self.stop_percentage[pid] = current_pl_perc - self.TRAILING_STOP.y(current_pl_perc)
|
||||
|
||||
# close position if current P/L below stop percentage
|
||||
if current_pl_perc < self.stop_percentage[pid]:
|
||||
await ss.add_event(Event(EventKind.CLOSE_POSITION, ss.current_tick, event_metadata))
|
||||
|
||||
return
|
@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='index.css') }}">
|
||||
</head>
|
||||
|
||||
<title>Rustico</title>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="{{ url_for('static', filename='index.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
1
websrc/.gitignore
vendored
1
websrc/.gitignore
vendored
@ -1 +0,0 @@
|
||||
*.js
|
@ -1,131 +0,0 @@
|
||||
import React, {Component} from "react";
|
||||
import {EventProp} from "./Events";
|
||||
import {
|
||||
Balance,
|
||||
CurrencyPair,
|
||||
EventName,
|
||||
FirstConnectMessage,
|
||||
NewEventMessage,
|
||||
NewTickMessage,
|
||||
PositionProp
|
||||
} from "../types";
|
||||
import {socket} from "../index";
|
||||
import {symbolToPair} from "../utils";
|
||||
import {Helmet} from "react-helmet";
|
||||
import {Navbar, Sidebar} from "./Navbars";
|
||||
import {Statusbar} from "./Statusbar";
|
||||
import {PositionsTable} from "./Tables";
|
||||
import RPlot from "./RPlot";
|
||||
|
||||
type AppState = {
|
||||
current_price: number,
|
||||
current_tick: number,
|
||||
last_update: Date,
|
||||
positions: Array<PositionProp>,
|
||||
events: Array<EventProp>,
|
||||
active_pair: CurrencyPair,
|
||||
available_pairs: Array<CurrencyPair>,
|
||||
balances: Array<Balance>
|
||||
}
|
||||
|
||||
class App extends Component<{}, AppState> {
|
||||
event_id = 0;
|
||||
|
||||
state = {
|
||||
current_price: 0,
|
||||
current_tick: 0,
|
||||
last_update: new Date(),
|
||||
positions: [],
|
||||
events: [],
|
||||
balances: [],
|
||||
active_pair: symbolToPair("tBTCUSD"),
|
||||
available_pairs: []
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
socket.on(EventName.FirstConnect, (data: FirstConnectMessage) => {
|
||||
this.setState({
|
||||
current_price: data.prices[data.prices.length - 1],
|
||||
current_tick: data.ticks[data.ticks.length - 1],
|
||||
last_update: new Date(),
|
||||
positions: data.positions,
|
||||
balances: data.balances
|
||||
})
|
||||
})
|
||||
|
||||
socket.on(EventName.NewTick, (data: NewTickMessage) => {
|
||||
this.setState({
|
||||
current_price: data.price,
|
||||
current_tick: data.tick,
|
||||
last_update: new Date(),
|
||||
positions: data.positions,
|
||||
balances: data.balances
|
||||
})
|
||||
})
|
||||
|
||||
socket.on(EventName.NewEvent, (data: NewEventMessage) => {
|
||||
// ignore new tick
|
||||
if (!data.kind.toLowerCase().includes("new_tick")) {
|
||||
const new_event: EventProp = {
|
||||
id: this.event_id,
|
||||
name: data.kind,
|
||||
tick: data.tick
|
||||
}
|
||||
|
||||
this.event_id += 1
|
||||
|
||||
this.setState((state) => ({
|
||||
events: [...state.events, new_event]
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title> Rustico
|
||||
- {String(this.state.current_price.toLocaleString())} {String(this.state.active_pair.base) + "/" + String(this.state.active_pair.quote)} </title>
|
||||
</Helmet>
|
||||
<div className="bg-gray-800">
|
||||
<div className="h-screen max-w-screen flex mx-auto">
|
||||
<Navbar/>
|
||||
|
||||
<main
|
||||
className="my-1 py-2 px-10 flex-1 bg-gray-200 dark:bg-black rounded-l-lg*
|
||||
transition duration-500 ease-in-out overflow-y-auto flex flex-col">
|
||||
<div className="flex justify-center text-2xl my-2">
|
||||
<Statusbar balances={this.state.balances} positions={this.state.positions}
|
||||
price={this.state.current_price}
|
||||
tick={this.state.current_tick}/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-grow my-8 shadow-md hover:shadow-lg">
|
||||
<div
|
||||
className="py-2 flex-grow bg-white min-width dark:bg-gray-600 rounded-lg overflow-hidden">
|
||||
<RPlot/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.state.positions.length > 0 ?
|
||||
<PositionsTable positions={this.state.positions}/> : null}
|
||||
|
||||
<footer className="flex rounded-lg justify-center bg-gray-600 mt-4 border-t text-gray-300">
|
||||
<span className="my-1 mx-1">Made with ❤️ by the Peperone in a scantinato</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<Sidebar/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
@ -1,155 +0,0 @@
|
||||
import React, {Component} from 'react';
|
||||
import {Balance, EventName, FirstConnectMessage, NewTickMessage} from "../types";
|
||||
import {socket} from "../index";
|
||||
|
||||
export type CoinBalanceProps = {
|
||||
name: string,
|
||||
amount: number,
|
||||
percentage?: number,
|
||||
quote_equivalent: number,
|
||||
quote_symbol: string,
|
||||
}
|
||||
|
||||
class CoinBalance extends Component<CoinBalanceProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
// do not print equivalent if this element is the quote itself
|
||||
const quoteBlock = this.props.name != this.props.quote_symbol ? this.props.quote_symbol.concat(" ").concat(this.props.quote_equivalent.toLocaleString()) : null
|
||||
|
||||
// const accessory = SymbolAccessories.filter((accessory) => {
|
||||
// return accessory.name == this.props.name
|
||||
// })
|
||||
//
|
||||
// const icon = accessory.length > 0 ? accessory.pop().icon : null
|
||||
|
||||
return (
|
||||
<div className="flex-grow flex px-6 py-3 text-gray-800 items-center border-b -mx-4 align-middle">
|
||||
<div className={"w-1/8"}>
|
||||
{/*{icon}*/}
|
||||
</div>
|
||||
<div className="w-2/5 xl:w-1/4 px-4 flex items-center">
|
||||
<span className="text-lg">{this.props.name}</span>
|
||||
</div>
|
||||
<div className="hidden md:flex lg:hidden xl:flex w-1/4 px-1 flex-col">
|
||||
<div className={"italic w-full text-center"}>
|
||||
{Math.trunc(this.props.percentage)}%
|
||||
</div>
|
||||
<div className="w-full bg-transparent mt-2">
|
||||
<div className={"bg-blue-400 rounded-lg text-xs leading-none py-1 text-center text-white"}
|
||||
style={{width: this.props.percentage.toString().concat("%")}}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-3/5 md:w/12">
|
||||
<div className="w-1/2 px-4">
|
||||
<div className="text-right">
|
||||
{this.props.amount.toFixed(5)} {this.props.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 px-4 my-auto">
|
||||
<div
|
||||
className={"px-2 inline-flex text-center text-xs leading-5 font-semibold rounded-full bg-gray-200 text-gray-800"}>
|
||||
{quoteBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type WalletCardProps = {
|
||||
quote: string,
|
||||
}
|
||||
|
||||
export class WalletCard extends Component
|
||||
<{}, { balances: Array<Balance> }> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
state =
|
||||
{
|
||||
balances: []
|
||||
}
|
||||
|
||||
totalQuoteBalance() {
|
||||
let total = 0
|
||||
|
||||
this.state.balances.forEach((balance: Balance) => {
|
||||
if (balance.currency == balance.quote) {
|
||||
total += balance.amount
|
||||
} else {
|
||||
total += balance.quote_equivalent
|
||||
}
|
||||
})
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
renderCoinBalances() {
|
||||
return (
|
||||
this.state.balances.map((balance: Balance) => {
|
||||
const percentage_amount = balance.quote == balance.currency ? balance.amount : balance.quote_equivalent;
|
||||
|
||||
return (
|
||||
<CoinBalance key={balance.currency.concat(balance.kind)} name={balance.currency}
|
||||
amount={balance.amount} quote_equivalent={balance.quote_equivalent}
|
||||
percentage={percentage_amount / this.totalQuoteBalance() * 100}
|
||||
quote_symbol={balance.quote}/>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
socket.on(EventName.NewTick, (data: NewTickMessage) => {
|
||||
this.setState({
|
||||
balances: data.balances
|
||||
})
|
||||
})
|
||||
|
||||
socket.on(EventName.FirstConnect, (data: FirstConnectMessage) => {
|
||||
this.setState({
|
||||
balances: data.balances
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="w-full mb-6 lg:mb-0 px-4 flex flex-col">
|
||||
<div
|
||||
className="flex-grow flex bg-white flex-col border-t border-b sm:rounded-lg sm:border shadow overflow-hidden">
|
||||
<div className="border-b bg-gray-50">
|
||||
<div className="flex justify-between px-6 -mb-px">
|
||||
<h3 className="text-blue-700 py-4 font-normal text-lg">Your Wallets</h3>
|
||||
<div className="flex">
|
||||
<button type="button"
|
||||
className="appearance-none py-4 text-blue-700 border-b border-blue-dark mr-3">
|
||||
Margin
|
||||
</button>
|
||||
<button type="button"
|
||||
className="appearance-none py-4 text-gray-600 border-b border-transparent hover:border-grey-dark">
|
||||
Chart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.renderCoinBalances()}
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-center text-gray-400">
|
||||
Total Balance ≈ USD {this.totalQuoteBalance().toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import {Button, ButtonGroup, Dropdown} from "react-bootstrap";
|
||||
import React, {Component} from "react";
|
||||
import DropdownItem from "react-bootstrap/DropdownItem";
|
||||
import {CurrencyPair} from "../types";
|
||||
|
||||
|
||||
export type CurrencyPairProps = {
|
||||
active_pair: CurrencyPair,
|
||||
pairs: Array<CurrencyPair>
|
||||
}
|
||||
|
||||
export class CurrencyDropdown extends Component<CurrencyPairProps> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
dropdownItems() {
|
||||
return this.props.pairs.map((pair) => {
|
||||
return (
|
||||
<DropdownItem key={String(pair.base) + String(pair.quote)}> {pair.base} / {pair.quote} </DropdownItem>)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Dropdown as={ButtonGroup} className={"mr-3"}>
|
||||
<Button variant="outline-primary"><b>{this.props.active_pair.base} / {this.props.active_pair.quote}</b></Button>
|
||||
|
||||
{this.props.pairs.length > 0 &&
|
||||
|
||||
<>
|
||||
<Dropdown.Toggle split variant="primary" id="dropdown-split-basic"/>
|
||||
|
||||
<Dropdown.Menu className={"mr-3"}>
|
||||
{this.dropdownItems()}
|
||||
</Dropdown.Menu>
|
||||
</>
|
||||
}
|
||||
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
import React, {Component} from "react";
|
||||
import {Container, ListGroup} from "react-bootstrap";
|
||||
|
||||
|
||||
export type EventProp = {
|
||||
id: number,
|
||||
name: string,
|
||||
tick: number
|
||||
}
|
||||
|
||||
export class Events extends Component<{ events: Array<EventProp> }> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
state = {
|
||||
events: this.props.events
|
||||
}
|
||||
|
||||
mapEvents() {
|
||||
return this.state.events.map((event: EventProp) => {
|
||||
return (
|
||||
<ListGroup.Item action key={event.id}>
|
||||
{event.name} @ Tick {event.tick}
|
||||
</ListGroup.Item>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<div className={"border-bottom mb-2"}>
|
||||
<h2>Events</h2>
|
||||
</div>
|
||||
<ListGroup>
|
||||
{this.mapEvents()}
|
||||
</ListGroup>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
import React, {Component} from "react";
|
||||
|
||||
type IconProps = {
|
||||
width: number,
|
||||
height: number,
|
||||
}
|
||||
|
||||
class Icon extends Component <IconProps> {
|
||||
private readonly width: string;
|
||||
private readonly height: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.width = "w-" + this.props.width.toString();
|
||||
this.height = "h-" + this.props.width.toString();
|
||||
}
|
||||
|
||||
dimensionsClassName() {
|
||||
return (this.height + " " + this.width)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class DollarIcon extends Icon {
|
||||
render() {
|
||||
return (
|
||||
<svg className={this.dimensionsClassName()} xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ClockIcon extends Icon {
|
||||
render() {
|
||||
return (
|
||||
<svg className={this.dimensionsClassName()} xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,71 +0,0 @@
|
||||
import React, {Component} from "react";
|
||||
import {WalletCard} from "./Cards";
|
||||
|
||||
export class Navbar extends Component<any, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<nav
|
||||
className="ml-1 w-24 flex flex-col items-center bg-gray-700 dark:bg-gray-700 py-4 my-1 rounded-tl rounded-bl">
|
||||
|
||||
<ul className="flex-1 mt-2 text-gray-700 dark:text-gray-400 capitalize">
|
||||
{/* Links */}
|
||||
<li className="mt-3 p-2 text-gray-400 dark:text-blue-300 rounded-lg">
|
||||
<a href="#" className=" flex flex-col items-center">
|
||||
<svg className="fill-current h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19 5v2h-4V5h4M9 5v6H5V5h4m10 8v6h-4v-6h4M9
|
||||
17v2H5v-2h4M21 3h-8v6h8V3M11 3H3v10h8V3m10
|
||||
8h-8v10h8V11m-10 4H3v6h8v-6z"/>
|
||||
</svg>
|
||||
<span className="text-xs mt-2 text-gray-300">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li className="mt-3 p-2 text-gray-400 dark:text-blue-300 rounded-lg">
|
||||
<a href="#" className=" flex flex-col items-center">
|
||||
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
|
||||
</svg>
|
||||
<span className="text-xs mt-2 text-gray-300">Reports</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="text-gray-700 dark:text-gray-400 capitalize">
|
||||
<li className="mt-auto p-2 text-gray-400 dark:text-blue-300 rounded-lg">
|
||||
<a href="#" className=" flex flex-col items-center">
|
||||
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<span className="text-xs mt-2 text-gray-300">Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class Sidebar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<aside
|
||||
className="w-1/4 my-1 mr-1 pr-2 py-4 flex flex-col bg-gray-200 dark:bg-black
|
||||
dark:text-gray-400 rounded-r-lg overflow-y-auto">
|
||||
<WalletCard/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import React, {Component} from "react";
|
||||
import {socket} from "../index";
|
||||
import {EventName} from "../types";
|
||||
|
||||
export type ModalProps = {
|
||||
show: boolean,
|
||||
positionId: number,
|
||||
toggleConfirmation: any
|
||||
}
|
||||
|
||||
export class ClosePositionModal extends Component<ModalProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed z-10 inset-0 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-gray-500 opacity-75"/>
|
||||
</div>
|
||||
|
||||
{/*This element is to trick the browser into centering the modal contents. -->*/}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true">​</span>
|
||||
|
||||
{/*Modal panel, show/hide based on modal state.*/}
|
||||
|
||||
{/*Entering: "ease-out duration-300"*/}
|
||||
{/* From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"*/}
|
||||
{/* To: "opacity-100 translate-y-0 sm:scale-100"*/}
|
||||
{/*Leaving: "ease-in duration-200"*/}
|
||||
{/* From: "opacity-100 translate-y-0 sm:scale-100"*/}
|
||||
{/* To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"*/}
|
||||
|
||||
<div
|
||||
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
role="dialog" aria-modal="true" aria-labelledby="modal-headline">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div
|
||||
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
{/*Heroicon name: exclamation -->*/}
|
||||
<svg className="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
|
||||
Close position
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Are you sure you want to close the position? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={event => {
|
||||
socket.emit(EventName.ClosePosition, {
|
||||
position_id: this.props.positionId
|
||||
})
|
||||
|
||||
this.props.toggleConfirmation()
|
||||
}}>
|
||||
Close
|
||||
</button>
|
||||
<button type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={() => this.props.toggleConfirmation()}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,168 +0,0 @@
|
||||
import React, {Component} from "react"
|
||||
import Plot from "react-plotly.js"
|
||||
|
||||
import {socket} from '../';
|
||||
import {EventName, NewTickMessage} from "../types";
|
||||
|
||||
|
||||
type FirstConnectData = {
|
||||
ticks: Array<number>,
|
||||
prices: Array<number>
|
||||
}
|
||||
|
||||
|
||||
type PriceLine = {
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number
|
||||
}
|
||||
|
||||
type PlotState = {
|
||||
x: Array<number>,
|
||||
y: Array<number>,
|
||||
current_price_line: PriceLine,
|
||||
positions_price_lines: Array<PriceLine>,
|
||||
}
|
||||
|
||||
class RPlot extends Component<{}, PlotState> {
|
||||
state = {
|
||||
x: [],
|
||||
y: [],
|
||||
current_price_line: {x0: 0, x1: 0, y0: 0, y1: 0},
|
||||
positions_price_lines: []
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
socket.on(EventName.FirstConnect, (data: FirstConnectData) => {
|
||||
const last_tick = data.ticks[data.ticks.length - 1];
|
||||
const last_price = data.prices[data.prices.length - 1];
|
||||
|
||||
this.setState({
|
||||
x: data.ticks,
|
||||
y: data.prices,
|
||||
current_price_line: {
|
||||
x0: 0,
|
||||
y0: last_price,
|
||||
x1: last_tick,
|
||||
y1: last_price
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
socket.on(EventName.NewTick, (data: NewTickMessage) => {
|
||||
const position_price_lines = data.positions.map((pstat): PriceLine => {
|
||||
return {
|
||||
x0: 0,
|
||||
y0: pstat.base_price,
|
||||
x1: data.tick,
|
||||
y1: pstat.base_price
|
||||
}
|
||||
})
|
||||
|
||||
this.setState((state) => ({
|
||||
x: state.x.concat(data.tick),
|
||||
y: state.y.concat(data.price),
|
||||
current_price_line: {
|
||||
x0: 0,
|
||||
y0: data.price,
|
||||
x1: data.tick,
|
||||
y1: data.price
|
||||
},
|
||||
positions_price_lines: position_price_lines
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
let additional_shapes = []
|
||||
|
||||
if (this.state.positions_price_lines.length > 0) {
|
||||
additional_shapes = this.state.positions_price_lines.map((priceline: PriceLine) => {
|
||||
return {
|
||||
type: 'line',
|
||||
x0: priceline.x0,
|
||||
y0: priceline.y0,
|
||||
x1: priceline.x1,
|
||||
y1: priceline.y1,
|
||||
line: {
|
||||
color: 'rgb(1, 1, 1)',
|
||||
width: 1,
|
||||
dash: 'solid'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
x: this.state.x,
|
||||
y: this.state.y,
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
},
|
||||
]}
|
||||
layout={{
|
||||
margin: {
|
||||
l: 110,
|
||||
r: 20,
|
||||
b: 100,
|
||||
t: 20,
|
||||
pad: 4
|
||||
},
|
||||
dragmode: "pan",
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: this.state.current_price_line.x0,
|
||||
y0: this.state.current_price_line.y0,
|
||||
x1: this.state.current_price_line.x1,
|
||||
y1: this.state.current_price_line.y1,
|
||||
line: {
|
||||
color: 'rgb(50, 171, 96)',
|
||||
width: 2,
|
||||
dash: 'dashdot'
|
||||
}
|
||||
},
|
||||
].concat(additional_shapes),
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Tick',
|
||||
font: {
|
||||
family: 'Courier New, monospace',
|
||||
size: 18,
|
||||
color: '#7f7f7f'
|
||||
}
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Price',
|
||||
font: {
|
||||
family: 'Courier New, monospace',
|
||||
size: 18,
|
||||
color: '#7f7f7f'
|
||||
}
|
||||
},
|
||||
tickformat: 'r',
|
||||
}
|
||||
}}
|
||||
config={{
|
||||
scrollZoom: true,
|
||||
displayModeBar: false,
|
||||
responsive: true,
|
||||
}}
|
||||
style={{width: '100%', height: '100%'}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default RPlot;
|
@ -1,48 +0,0 @@
|
||||
import React, {Component} from 'react';
|
||||
import {Toast} from 'react-bootstrap';
|
||||
import {socket} from "../";
|
||||
|
||||
type ToastProps = {
|
||||
title: string,
|
||||
content?: string,
|
||||
bg?: string
|
||||
}
|
||||
|
||||
type ToastState = {
|
||||
lastUpdated: Date,
|
||||
show: boolean
|
||||
}
|
||||
|
||||
|
||||
export class RToast extends Component<ToastProps, ToastState> {
|
||||
state = {
|
||||
lastUpdated: new Date(),
|
||||
show: false
|
||||
}
|
||||
|
||||
constructor(props: ToastProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
socket.on("connect", () => {
|
||||
this.setState({show: true})
|
||||
})
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.setState({lastUpdated: new Date()})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Toast show={this.state.show} delay={5000} autohide>
|
||||
<Toast.Header>
|
||||
<strong className="mr-auto">{this.props.title}</strong>
|
||||
<small>{this.state.lastUpdated.toLocaleTimeString('en-GB')}</small>
|
||||
</Toast.Header>
|
||||
{this.props.content ? <Toast.Body> {this.props.content}</Toast.Body> : null}
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,293 +0,0 @@
|
||||
import React, {Component} from "react";
|
||||
import {EventName, GetProfitLossMessage, NewTickMessage, PutProfitLossMessage} from "../types";
|
||||
import {socket} from "../index";
|
||||
import {DateTime} from "luxon";
|
||||
|
||||
type QuoteStatusProps = {
|
||||
percentage?: boolean,
|
||||
quote_symbol?: string,
|
||||
amount: number,
|
||||
subtitle: string,
|
||||
sign?: boolean
|
||||
}
|
||||
|
||||
export class QuoteStatus extends Component<QuoteStatusProps> {
|
||||
private whole: number;
|
||||
private decimal: number;
|
||||
private sign: string;
|
||||
private signClass: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.deriveProps()
|
||||
}
|
||||
|
||||
deriveProps() {
|
||||
this.whole = Math.abs(Math.trunc(this.props.amount))
|
||||
this.decimal = Math.trunc(this.props.amount % 1 * 100)
|
||||
this.sign = this.props.amount > 0 ? "+" : "-"
|
||||
|
||||
this.signClass = this.props.amount > 0 ? "text-green-500" : "text-red-500"
|
||||
}
|
||||
|
||||
renderSign() {
|
||||
if (this.props.sign) {
|
||||
return (
|
||||
<span
|
||||
className={this.signClass}>{this.sign}</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
symbolOrPercentageRender() {
|
||||
if (this.props.percentage) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-4xl text-bold align-top">
|
||||
{this.renderSign()}</span>
|
||||
<span className="text-5xl">{Math.abs(this.props.amount).toFixed(2)}</span>
|
||||
<span className="text-3xl align-top">%</span>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<span className="text-4xl text-bold align-top">{this.renderSign()}{this.props.quote_symbol}</span>
|
||||
<span className="text-5xl">{this.whole.toLocaleString()}</span>
|
||||
<span className="text-3xl align-top">.{Math.abs(this.decimal)}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<div className="text-gray-700 mb-2">
|
||||
{this.symbolOrPercentageRender()}
|
||||
</div>
|
||||
<div className="text-sm uppercase text-gray-300 tracking-wide">
|
||||
{this.props.subtitle}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type DateButtonProps = {
|
||||
label: string,
|
||||
onClick: any,
|
||||
selected_default?: boolean
|
||||
}
|
||||
|
||||
type DateButtonState = {
|
||||
selected: boolean,
|
||||
}
|
||||
|
||||
class DateButton extends Component<DateButtonProps, DateButtonState> {
|
||||
private classSelected: string = "appearance-none py-4 text-blue-600 border-b border-blue-600 mr-3";
|
||||
private classNotSelected: string = "appearance-none py-4 text-gray-600 border-b border-transparent hover:border-gray-800 mr-3";
|
||||
private currentClass: string;
|
||||
|
||||
state = {
|
||||
selected: this.props.selected_default
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.updateClass()
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.setState({selected: !this.state.selected}, this.updateClass)
|
||||
|
||||
this.props.onClick()
|
||||
}
|
||||
|
||||
updateClass() {
|
||||
this.currentClass = this.state.selected ? this.classSelected : this.classNotSelected
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<button key={this.props.label} type="button"
|
||||
className={this.currentClass} onClick={this.onClick.bind(this)}>
|
||||
{this.props.label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const PeriodUnit = {
|
||||
SECOND: "second",
|
||||
MINUTE: "minute",
|
||||
HOUR: "hour",
|
||||
DAY: "day",
|
||||
WEEK: "week",
|
||||
MONTH: "month",
|
||||
YEAR: "year"
|
||||
}
|
||||
|
||||
type StatusBarState = {
|
||||
pl_period_unit: string,
|
||||
pl_period_amount: number,
|
||||
pl: number,
|
||||
pl_perc: number
|
||||
}
|
||||
|
||||
export class Statusbar extends Component<NewTickMessage, StatusBarState> {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
pl_period_unit: PeriodUnit.WEEK,
|
||||
pl_period_amount: 1,
|
||||
pl: 0.0,
|
||||
pl_perc: 0.0
|
||||
}
|
||||
|
||||
this.emitGetProfitLoss = this.emitGetProfitLoss.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
socket.on(EventName.PutProfitLoss, (data: PutProfitLossMessage) => {
|
||||
this.setState({
|
||||
pl: data.pl,
|
||||
pl_perc: data.pl_perc
|
||||
})
|
||||
})
|
||||
|
||||
socket.on(EventName.FirstConnect, this.emitGetProfitLoss)
|
||||
socket.on(EventName.NewTick, this.emitGetProfitLoss)
|
||||
}
|
||||
|
||||
|
||||
durationObjectfromStr(str: string) {
|
||||
switch (str) {
|
||||
case PeriodUnit.MINUTE:
|
||||
return {
|
||||
minutes: this.state.pl_period_amount
|
||||
}
|
||||
case PeriodUnit.HOUR:
|
||||
return {
|
||||
hours: this.state.pl_period_amount
|
||||
}
|
||||
case PeriodUnit.DAY:
|
||||
return {
|
||||
days: this.state.pl_period_amount
|
||||
}
|
||||
case PeriodUnit.WEEK:
|
||||
return {
|
||||
weeks: this.state.pl_period_amount
|
||||
}
|
||||
case PeriodUnit.MONTH:
|
||||
return {
|
||||
months: this.state.pl_period_amount
|
||||
}
|
||||
case PeriodUnit.YEAR:
|
||||
return {
|
||||
years: this.state.pl_period_amount
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
emitGetProfitLoss() {
|
||||
const message: GetProfitLossMessage = {
|
||||
start: DateTime.local().minus(this.durationObjectfromStr(this.state.pl_period_unit)).toMillis(),
|
||||
end: DateTime.local().toMillis()
|
||||
}
|
||||
|
||||
socket.emit(EventName.GetProfitLoss, message)
|
||||
}
|
||||
|
||||
changeProfitLossPeriod(amount: number, unit: string) {
|
||||
this.setState({
|
||||
pl_period_amount: amount,
|
||||
pl_period_unit: unit
|
||||
}, this.emitGetProfitLoss)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="bg-white border-t border-b sm:border-l sm:border-r sm:rounded-lg shadow flex-grow mb-6">
|
||||
<div className="bg-gray-50 rounded-tl-lg rounded-tr-lg border-b px-6">
|
||||
<div className="flex justify-between -mb-px">
|
||||
<div className="lg:hidden text-blue-600 py-4 text-lg">
|
||||
Price Charts
|
||||
</div>
|
||||
<div className="hidden lg:flex">
|
||||
<button type="button"
|
||||
className="appearance-none py-4 text-blue-600 border-b border-blue-dark mr-6">
|
||||
Bitcoin
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex text-sm">
|
||||
<DateButton key={PeriodUnit.MINUTE} label={"1m"}
|
||||
onClick={() => this.changeProfitLossPeriod(1, PeriodUnit.MINUTE)}/>
|
||||
<DateButton key={PeriodUnit.DAY} label={"1D"}
|
||||
onClick={() => this.changeProfitLossPeriod(1, PeriodUnit.DAY)}/>
|
||||
<DateButton key={PeriodUnit.WEEK} label={"1W"} selected_default={true}
|
||||
onClick={() => this.changeProfitLossPeriod(1, PeriodUnit.WEEK)}/>
|
||||
<DateButton key={PeriodUnit.MONTH} label={"1M"}
|
||||
onClick={() => this.changeProfitLossPeriod(1, PeriodUnit.MONTH)}/>
|
||||
<DateButton key={PeriodUnit.YEAR} label={"1Y"}
|
||||
onClick={() => this.changeProfitLossPeriod(1, PeriodUnit.YEAR)}/>
|
||||
{/*<DateButton label={"ALL"} onClick={() => this.changeProfitLossPeriod(1, PeriodUnit.MINUTE)}/>*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center px-6 lg:hidden">
|
||||
<div className="flex-grow flex-no-shrink py-6">
|
||||
<div className="text-gray-700 mb-2">
|
||||
<span className="text-3xl align-top">CA$</span>
|
||||
<span className="text-5xl">21,404</span>
|
||||
<span className="text-3xl align-top">.74</span>
|
||||
</div>
|
||||
<div className="text-green-300 text-sm">
|
||||
↑ CA$12,955.35 (154.16%)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink w-32 inline-block relative">
|
||||
<select
|
||||
className="block appearance-none w-full bg-white border border-grey-light px-4 py-2 pr-8 rounded">
|
||||
<option>BTC</option>
|
||||
</select>
|
||||
<div className="pointer-events-none absolute pin-y pin-r flex items-center px-2 text-gray-300">
|
||||
<svg className="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20">
|
||||
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex">
|
||||
<div className="w-1/3 text-center py-8">
|
||||
<div className="border-r">
|
||||
<QuoteStatus key={this.props.price} quote_symbol={"USD"} amount={this.props.price}
|
||||
subtitle={"Bitcoin price"}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 text-center py-8">
|
||||
<div className="border-r">
|
||||
<QuoteStatus key={this.state.pl} quote_symbol={"USD"} sign={true} amount={this.state.pl}
|
||||
subtitle={"since last ".concat(this.state.pl_period_unit)}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 text-center py-8">
|
||||
<div>
|
||||
<QuoteStatus key={this.state.pl_perc} percentage={true} sign={true}
|
||||
amount={this.state.pl_perc}
|
||||
subtitle={"since last ".concat(this.state.pl_period_unit)}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
import React, {Component} from "react"
|
||||
import {PositionProp} from "../types";
|
||||
import {ClosePositionModal} from "./Overlays";
|
||||
|
||||
type PositionsTableState = {
|
||||
showConfirmation: boolean,
|
||||
positionToClose: number
|
||||
}
|
||||
|
||||
export class PositionsTable extends Component<{ positions: Array<PositionProp> }, PositionsTableState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.toggleConfirmation = this.toggleConfirmation.bind(this)
|
||||
}
|
||||
|
||||
state = {
|
||||
showConfirmation: false,
|
||||
positionToClose: 0,
|
||||
}
|
||||
|
||||
toggleConfirmation() {
|
||||
this.setState((state) => ({
|
||||
showConfirmation: !state.showConfirmation
|
||||
}))
|
||||
}
|
||||
|
||||
stateColor(state: string): string {
|
||||
const lower_state = state.toLowerCase()
|
||||
let res: string
|
||||
|
||||
if (lower_state.includes("profit")) {
|
||||
res = "green"
|
||||
} else if (lower_state.includes("break")) {
|
||||
res = "yellow"
|
||||
} else {
|
||||
res = "red"
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
renderTableHead() {
|
||||
return ["status", "currency pair", "base price", "amount", "Profit/Loss", "actions"].map((entry) => {
|
||||
return (
|
||||
<th scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{entry}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
renderTableRows() {
|
||||
return this.props.positions.map((position) => {
|
||||
// TODO: move symbolToPair out of here?
|
||||
const stateBg = "bg-".concat(this.stateColor(position.state)).concat("-100 ")
|
||||
const stateText = "text-".concat(this.stateColor(position.state)).concat("-800 ")
|
||||
const stateClass = "px-2 inline-flex text-xs leading-5 font-semibold rounded-full ".concat(stateBg).concat(stateText)
|
||||
|
||||
return (
|
||||
<tr key={position.id}>
|
||||
{/* Status */}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={stateClass}>
|
||||
{position.state}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-1 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{position.pair.base}/{position.pair.quote}</div>
|
||||
{/*<div className="text-sm text-gray-500">{position.}</div>*/}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-1 whitespace-nowrap">
|
||||
<div
|
||||
className="text-sm text-gray-900">{position.base_price.toLocaleString()} {position.pair.quote}/{position.pair.base}</div>
|
||||
{/*<div className="text-sm text-gray-500">Insert total % here?</div>*/}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-1 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{position.amount.toFixed(5)} {position.pair.base}</div>
|
||||
<div className="text-sm text-gray-500">Insert total % here?</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-1 whitespace-nowrap">
|
||||
<div
|
||||
className="text-sm text-gray-900 font-semibold">{position.profit_loss.toLocaleString()} {position.pair.quote}</div>
|
||||
<div className={"text-sm ".concat(stateClass)}>{position.profit_loss_percentage.toFixed(2)}%
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-1 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="p-2 md:w-40">
|
||||
<div
|
||||
className="p-4 flex justify-center bg-red-200 rounded-lg shadow-xs cursor-pointer hover:bg-red-500 hover:text-red-100"
|
||||
onClick={() => {
|
||||
this.setState({
|
||||
showConfirmation: true,
|
||||
positionToClose: position.id
|
||||
})
|
||||
}}>
|
||||
<span className="ml-2">Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<ClosePositionModal positionId={this.state.positionToClose} toggleConfirmation={this.toggleConfirmation}
|
||||
show={this.state.showConfirmation}/>
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{this.renderTableHead()}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{this.renderTableRows()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
}
|
1
websrc/css/.gitignore
vendored
1
websrc/css/.gitignore
vendored
@ -1 +0,0 @@
|
||||
tailwind.css
|
@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./css/tailwind.css";
|
||||
import App from "./components/App";
|
||||
import io from "socket.io-client";
|
||||
|
||||
export const socket = io();
|
||||
|
||||
socket.on("connect", function () {
|
||||
console.log("Connected!")
|
||||
})
|
||||
|
||||
ReactDOM.render(<App/>, document.getElementById("root"));
|
@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "rustico",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^1.25.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-helmet": "^6.1.0",
|
||||
"@types/react-plotly.js": "^2.2.4",
|
||||
"@types/socket.io-client": "^1.4.34",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
"react-plotly.js": "^2.5.1",
|
||||
"typescript": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/classnames": "^2.2.11",
|
||||
"autoprefixer": "^10.1.0",
|
||||
"classnames": "^2.2.6",
|
||||
"css": "^3.0.0",
|
||||
"luxon": "^1.25.0",
|
||||
"plotly.js": "^1.58.2",
|
||||
"postcss": "^8.2.1",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"react": "^17.0.1",
|
||||
"react-cryptocoins": "^1.0.11",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"socket.io-client": "~3",
|
||||
"tailwindcss": "^2.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build:tailwind": "tailwindcss build css/index.css -o css/tailwind.css",
|
||||
"watch": "parcel watch index.tsx -d ../static",
|
||||
"prestart": "yarn run build:tailwind",
|
||||
"prebuild": "yarn run build:tailwind",
|
||||
"start": "python ../main.py & yarn run watch"
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "DOM"],
|
||||
"jsx": "react",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
import {EventProp} from "./components/Events";
|
||||
|
||||
/*******************************
|
||||
* Types
|
||||
*******************************/
|
||||
|
||||
export type Balance = {
|
||||
currency: string,
|
||||
amount: number,
|
||||
// exchange / margin
|
||||
kind: string,
|
||||
quote: string,
|
||||
quote_equivalent: number,
|
||||
}
|
||||
|
||||
export type CurrencyPair = {
|
||||
base: string,
|
||||
quote: string
|
||||
}
|
||||
|
||||
export type FirstConnectMessage = {
|
||||
ticks: Array<number>,
|
||||
prices: Array<number>,
|
||||
positions: Array<PositionProp>,
|
||||
balances: Array<Balance>
|
||||
}
|
||||
|
||||
export type GetProfitLossMessage = {
|
||||
start: number,
|
||||
end: number
|
||||
}
|
||||
|
||||
export type NewEventMessage = {
|
||||
tick: number,
|
||||
kind: string,
|
||||
}
|
||||
|
||||
export type NewTickMessage = {
|
||||
tick: number,
|
||||
price: number,
|
||||
positions: Array<PositionProp>,
|
||||
balances: Array<Balance>
|
||||
}
|
||||
|
||||
export type PositionCloseMessage = {
|
||||
message_name: string,
|
||||
position_id: number,
|
||||
}
|
||||
|
||||
export type PositionProp = {
|
||||
id: number,
|
||||
state: string,
|
||||
base_price: number,
|
||||
amount: number,
|
||||
pair: CurrencyPair,
|
||||
profit_loss: number,
|
||||
profit_loss_percentage: number
|
||||
}
|
||||
|
||||
export type PutProfitLossMessage = {
|
||||
pl: number,
|
||||
pl_perc: number
|
||||
}
|
||||
|
||||
/*******************************
|
||||
* ENUMS
|
||||
*******************************/
|
||||
|
||||
export enum EventName {
|
||||
NewTick = "new_tick",
|
||||
FirstConnect = "first_connect",
|
||||
NewEvent = "new_event",
|
||||
ClosePosition = "close_position",
|
||||
GetProfitLoss = "get_profit_loss",
|
||||
PutProfitLoss = "put_profit_loss"
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
import {CurrencyPair} from "./types";
|
||||
// import * as Icon from 'react-cryptocoins'
|
||||
import {Btc, Eth, Xmr} from 'react-cryptocoins';
|
||||
|
||||
export function symbolToPair(symbol: string): CurrencyPair {
|
||||
const symbol_regex = "t(?<base>[a-zA-Z]{3})(?<quote>[a-zA-Z]{3})"
|
||||
|
||||
const match = symbol.match(symbol_regex)
|
||||
|
||||
return {
|
||||
base: match.groups.base,
|
||||
quote: match.groups.quote
|
||||
}
|
||||
}
|
||||
|
||||
export type SymbolAccesory = {
|
||||
name: string,
|
||||
icon: React.Component,
|
||||
color: string
|
||||
}
|
||||
|
||||
export const SymbolAccessories: Array<SymbolAccesory> = [
|
||||
{
|
||||
name: "BTC",
|
||||
icon: Btc,
|
||||
color: "yellow"
|
||||
},
|
||||
{
|
||||
name: "XMR",
|
||||
icon: Xmr,
|
||||
color: "yellow"
|
||||
},
|
||||
{
|
||||
name: "ETH",
|
||||
icon: Eth,
|
||||
color: "yellow"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
8826
websrc/yarn.lock
8826
websrc/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user