>};
+type RawType> = P extends Promise ? U : never;
+
type ValueOf = T[keyof T];
type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
diff --git a/client/src/App.tsx b/client/src/App.tsx
index ae53a251..45eedb90 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,57 +1,29 @@
import React from 'react';
-import {useDispatch} from 'react-redux';
import {Box} from '@chakra-ui/react';
-import {
- ScaledOrders,
- MarketOrderContainer,
- TrailingLimitOrder,
- TickerPricesContainer,
- // CrossOrderContainer,
- OpenOrdersContainer,
-} from 'containers';
-import {Spinner, ToastContainer} from 'components';
-import {useReduxSelector} from 'redux/helpers/hookHelpers';
-import {wsConnect, wsDisconnect, wsSubscribeTo, wsAuthenticate} from 'redux/modules/websocket/websocketModule';
-import {getBalance} from 'redux/modules/preview/previewModule';
-import 'scss/root.module.scss';
-
-const App = React.memo(() => {
- const dispatch = useDispatch();
- const {previewLoading, trailLoading, wsLoading, connected} = useReduxSelector(
- 'previewLoading',
- 'trailLoading',
- 'wsLoading',
- 'connected',
- );
-
- React.useEffect(() => {
- dispatch(wsConnect());
+import {Route, Switch} from 'react-router-dom';
+import {ExchangeRoute, Header} from 'components';
+import {Exchange} from 'redux/modules/settings/types';
+import Home from 'pages/Home';
+import Settings from 'pages/Settings';
+import NotFound from 'pages/NotFound';
+import BitmexExchange from 'pages/Bitmex';
+import {RoutePath} from 'pages/paths';
- return () => {
- dispatch(wsDisconnect());
- };
- }, [dispatch]);
-
- React.useEffect(() => {
- if (connected) {
- dispatch(getBalance());
- dispatch(wsAuthenticate());
- dispatch(wsSubscribeTo('order'));
- }
- }, [dispatch, connected]);
+import 'scss/root.module.scss';
+export default React.memo(function App() {
return (
-
-
-
-
-
- {/* TODO: disabling for now */}
-
-
-
-
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
);
});
-
-export default App;
diff --git a/client/src/components/Banner/Banner.tsx b/client/src/components/Banner/Banner.tsx
new file mode 100644
index 00000000..9eeecce1
--- /dev/null
+++ b/client/src/components/Banner/Banner.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import {WarningIcon} from '@chakra-ui/icons';
+import {Box} from '@chakra-ui/react';
+import {Link} from 'react-router-dom';
+import {useExchange} from 'general/hooks';
+import {ExchangePresenter} from 'presenters/general-presenters';
+
+export function Banner() {
+ const exchange = useExchange();
+ return (
+
+
+
+ There is no API key for {ExchangePresenter[exchange]} exchange. Add it{' '}
+
+ here
+
+
+
+ );
+}
diff --git a/client/src/components/Button/Button.module.scss b/client/src/components/Button/Button.module.scss
index 9a60ef03..d216a3db 100644
--- a/client/src/components/Button/Button.module.scss
+++ b/client/src/components/Button/Button.module.scss
@@ -20,8 +20,9 @@
background-color: var(--primaryColor);
border-radius: 2px;
padding: 5px 5px;
- width: 120px;
- height: 30px;
+ width: auto;
+ min-width: 120px;
+ height: 34px;
&:focus:enabled {
-webkit-box-shadow: none;
box-shadow: none;
@@ -69,6 +70,16 @@
}
}
+.outline {
+ @extend %regular;
+ color: var(--stopColor);
+ border: 1px solid var(--stopColor);
+ &:hover:enabled {
+ border: 1px solid rgb(245, 157, 172);
+ color: rgb(245, 157, 172);
+ }
+}
+
.button_buy {
@extend %regular;
border: 1px solid var(--accentColor);
diff --git a/client/src/components/Button/Button.tsx b/client/src/components/Button/Button.tsx
index e9d28383..1eb2e1d3 100644
--- a/client/src/components/Button/Button.tsx
+++ b/client/src/components/Button/Button.tsx
@@ -5,7 +5,7 @@ import {SIDE} from '../../redux/api/bitmex/types';
import {COMPONENTS} from 'data-test-ids';
import styles from './Button.module.scss';
-export type ButtonVariants = 'submit' | 'text' | 'custom' | 'textSell' | SIDE;
+export type ButtonVariants = 'submit' | 'text' | 'custom' | 'textSell' | 'outline' | SIDE;
interface Props {
testID?: string;
@@ -36,6 +36,7 @@ export function Button({
[styles.text_sell]: variant === 'textSell',
[styles.button_buy]: variant === 'Buy',
[styles.button_sell]: variant === 'Sell',
+ [styles.outline]: variant === 'outline',
[className]: variant === 'custom',
});
diff --git a/client/src/components/ExchangeRoute/ExchangeRoute.tsx b/client/src/components/ExchangeRoute/ExchangeRoute.tsx
new file mode 100644
index 00000000..3e80c947
--- /dev/null
+++ b/client/src/components/ExchangeRoute/ExchangeRoute.tsx
@@ -0,0 +1,36 @@
+import {Heading} from '@chakra-ui/react';
+import React from 'react';
+import {useDispatch, useSelector} from 'react-redux';
+import {Route} from 'react-router-dom';
+import {activateExchange, getAllApiKeys} from 'redux/modules/settings/settingsModule';
+import {Exchange} from 'redux/modules/settings/types';
+import {AppState} from 'redux/modules/state';
+
+interface Props {
+ path: string;
+ exact?: boolean;
+ exchange: Exchange;
+ component: React.ComponentType;
+}
+
+export function ExchangeRoute({path, exact, exchange, component}: Props) {
+ const dispatch = useDispatch();
+
+ const currentExchange = useSelector((state: AppState) => state.settings.activeExchange);
+ const loading = useSelector((state: AppState) => state.settings.getAllApiKeysLoading);
+
+ React.useEffect(() => {
+ dispatch(activateExchange(exchange));
+ dispatch(getAllApiKeys());
+ }, [dispatch, exchange]);
+
+ return (
+
+ {loading ? (
+ Loading...
+ ) : currentExchange === exchange ? (
+ React.createElement(component)
+ ) : null}
+
+ );
+}
diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx
new file mode 100644
index 00000000..853c7701
--- /dev/null
+++ b/client/src/components/Header/Header.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import {Flex, Heading, Box} from '@chakra-ui/react';
+import {SettingsIcon} from '@chakra-ui/icons';
+import {NavLink} from 'react-router-dom';
+import {RoutePath} from 'pages/paths';
+
+const MenuItem = ({children, to}: any) => (
+
+
+ {children}
+
+
+);
+
+export function Header() {
+ return (
+
+
+ Home
+ BitMeX
+ BitMex Testnet
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/InputField/InputField.tsx b/client/src/components/InputField/InputField.tsx
index 67a55f93..66bd25be 100644
--- a/client/src/components/InputField/InputField.tsx
+++ b/client/src/components/InputField/InputField.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import {Box, NumberInput, NumberInputField} from '@chakra-ui/react';
+import {Box, NumberInput, NumberInputField, Input} from '@chakra-ui/react';
import './InputField.module.scss';
interface Props {
@@ -13,13 +13,18 @@ interface Props {
tooltip?: string;
onChange: (value: any, id: string) => void;
step?: number;
+ type?: 'number' | 'text';
}
export function InputField(props: Props) {
- const {id, label, value, stop = false, placeholder, onChange, testID, step} = props;
+ const {id, label, value, stop = false, placeholder, onChange, testID, step, type = 'number'} = props;
const invokeValueChange = React.useCallback(
- (value: string) => onChange(step == undefined ? +value : value, id as string),
+ (value: string | any) => {
+ typeof value === 'string'
+ ? onChange(step == undefined ? +value : value, id as string)
+ : onChange(value?.target.value, id as string);
+ },
[onChange, id, step],
);
@@ -28,28 +33,43 @@ export function InputField(props: Props) {
{label}
-
-
+
+
+ ) : (
+
-
+ )}
);
}
diff --git a/client/src/components/index.ts b/client/src/components/index.ts
index f325cfbc..4af4ccfa 100644
--- a/client/src/components/index.ts
+++ b/client/src/components/index.ts
@@ -9,3 +9,5 @@ export * from './Toast/Toast';
export * from './Modal/Modal';
export * from './Row/Row';
export * from './modals';
+export * from './Header/Header';
+export * from './ExchangeRoute/ExchangeRoute';
diff --git a/client/src/components/modals/AddApiKeysModal.tsx b/client/src/components/modals/AddApiKeysModal.tsx
new file mode 100644
index 00000000..390233ff
--- /dev/null
+++ b/client/src/components/modals/AddApiKeysModal.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import {useDispatch} from 'react-redux';
+import {Modal, InputField} from 'components';
+import {saveApiKey} from 'redux/modules/settings/settingsModule';
+import {Exchange} from 'redux/modules/settings/types';
+import {ExchangePresenter} from 'presenters/general-presenters';
+import {ADD_API_KEYS_MODAL} from 'data-test-ids';
+
+interface Props {
+ exchange: Exchange;
+}
+
+export function AddApiKeysModal({exchange}: Props) {
+ const dispatch = useDispatch();
+
+ const [key, setKey] = React.useState('');
+ const [secret, setSecret] = React.useState('');
+
+ const addTarget = React.useCallback(() => {
+ dispatch(saveApiKey({exchange, key, secret}));
+ }, [dispatch, exchange, key, secret]);
+
+ const isConfirmButtonDisabled = key.length < 15 || secret.length < 15;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/client/src/components/modals/AddProfitOrderModal.tsx b/client/src/components/modals/AddProfitOrderModal.tsx
index c64ef3a8..f7d90219 100644
--- a/client/src/components/modals/AddProfitOrderModal.tsx
+++ b/client/src/components/modals/AddProfitOrderModal.tsx
@@ -1,19 +1,19 @@
import React from 'react';
-import {useDispatch, useSelector} from 'react-redux';
+import {useSelector} from 'react-redux';
import {Modal, InputField} from 'components';
-import {addProfitTarget} from 'redux/modules/orders/ordersModule';
import {SIDE} from 'redux/api/bitmex/types';
import {AppState} from 'redux/modules/state';
import {orderSelector} from 'redux/selectors';
import {ADD_ORDER_MODAL} from 'data-test-ids';
import {INSTRUMENT_PARAMS} from 'utils';
+import {useAppContext} from 'general/hooks';
interface Props {
orderID: string;
}
export function AddProfitOrderModal({orderID}: Props) {
- const dispatch = useDispatch();
+ const {api} = useAppContext();
const order = useSelector((state: AppState) => orderSelector(state, {orderID}));
const {symbol, side, price: stopPx} = order!;
@@ -22,8 +22,8 @@ export function AddProfitOrderModal({orderID}: Props) {
const [quantity, setQuantity] = React.useState('');
const addTarget = React.useCallback(() => {
- dispatch(addProfitTarget({orderID, side, symbol, stop: stopPx, price: parseInt(price), orderQty: quantity}));
- }, [dispatch, orderID, side, quantity, price, stopPx, symbol]);
+ api.addProfitTarget({orderID, side, symbol, stop: stopPx, price: parseInt(price), orderQty: quantity});
+ }, [api, orderID, side, quantity, price, stopPx, symbol]);
const isConfirmButtonDisabled =
!parseInt(price) ||
diff --git a/client/src/components/modals/CancelAllOrdersModal.tsx b/client/src/components/modals/CancelAllOrdersModal.tsx
index 6a04cbd9..65158794 100644
--- a/client/src/components/modals/CancelAllOrdersModal.tsx
+++ b/client/src/components/modals/CancelAllOrdersModal.tsx
@@ -1,19 +1,16 @@
import React from 'react';
-import {useDispatch} from 'react-redux';
import {Modal} from 'components';
-import {cancelAllOrders} from 'redux/modules/orders/ordersModule';
+import {useAppContext} from 'general/hooks';
interface Props {
totalOrders: number;
}
export function CancelAllOrdersModal({totalOrders}: Props) {
- const dispatch = useDispatch();
-
- const emitConfirm = React.useCallback(() => void dispatch(cancelAllOrders()), [dispatch]);
+ const {api} = useAppContext();
return (
-
+
{`This will cancel ${totalOrders} order${totalOrders > 1 ? 's' : ''}`}
);
diff --git a/client/src/components/modals/CancelAllProfitOrdersModal.tsx b/client/src/components/modals/CancelAllProfitOrdersModal.tsx
index 63f408b2..34852b73 100644
--- a/client/src/components/modals/CancelAllProfitOrdersModal.tsx
+++ b/client/src/components/modals/CancelAllProfitOrdersModal.tsx
@@ -1,7 +1,6 @@
import React from 'react';
-import {useDispatch} from 'react-redux';
import {Modal} from 'components';
-import {cancelAllProfitOrders} from 'redux/modules/orders/ordersModule';
+import {useAppContext} from 'general/hooks';
interface Props {
totalOrders: number;
@@ -9,11 +8,12 @@ interface Props {
}
export function CancelAllProfitOrdersModal({totalOrders, profitOrderIds}: Props) {
- const dispatch = useDispatch();
+ const {api} = useAppContext();
- const emitConfirm = React.useCallback(() => {
- dispatch(cancelAllProfitOrders({orderID: profitOrderIds}));
- }, [dispatch, profitOrderIds]);
+ const emitConfirm = React.useCallback(
+ () => api.cancelAllProfitOrders({orderID: profitOrderIds}),
+ [api, profitOrderIds],
+ );
return (
diff --git a/client/src/components/modals/CancelOrderModal.tsx b/client/src/components/modals/CancelOrderModal.tsx
index f5f4cd7b..011b1c95 100644
--- a/client/src/components/modals/CancelOrderModal.tsx
+++ b/client/src/components/modals/CancelOrderModal.tsx
@@ -1,16 +1,16 @@
import React from 'react';
-import {useDispatch, useSelector} from 'react-redux';
+import {useSelector} from 'react-redux';
import {Modal} from 'components';
-import {cancelOrder} from 'redux/modules/orders/ordersModule';
import {AppState} from 'redux/modules/state';
import {groupedOrdersSelector, orderSelector} from 'redux/selectors';
+import {useAppContext} from 'general/hooks';
interface Props {
orderID: string;
}
export function CancelOrderModal({orderID}: Props) {
- const dispatch = useDispatch();
+ const {api} = useAppContext();
const order = useSelector((state: AppState) => orderSelector(state, {orderID}));
const groupedOrders = useSelector(groupedOrdersSelector);
@@ -22,9 +22,9 @@ export function CancelOrderModal({orderID}: Props) {
const emitConfirm = React.useCallback(() => {
if (order) {
- dispatch(cancelOrder({orderID: [order.orderID, ...profitOrderIDs]}));
+ api.cancelOrder({orderID: [order.orderID, ...profitOrderIDs]});
}
- }, [dispatch, profitOrderIDs, order]);
+ }, [api, profitOrderIDs, order]);
if (!order) {
return null;
diff --git a/client/src/components/modals/CancelProfitOrderModal.tsx b/client/src/components/modals/CancelProfitOrderModal.tsx
index 548b2585..8890558c 100644
--- a/client/src/components/modals/CancelProfitOrderModal.tsx
+++ b/client/src/components/modals/CancelProfitOrderModal.tsx
@@ -1,8 +1,7 @@
import React from 'react';
-import {useDispatch} from 'react-redux';
import {Modal} from 'components';
-import {cancelProfitOrder} from 'redux/modules/orders/ordersModule';
import {SYMBOL} from 'redux/api/bitmex/types';
+import {useAppContext} from 'general/hooks';
interface Props {
symbol: SYMBOL;
@@ -12,9 +11,9 @@ interface Props {
}
export function CancelProfitOrderModal({symbol, price, quantity, orderID}: Props) {
- const dispatch = useDispatch();
+ const {api} = useAppContext();
- const emitConfirm = React.useCallback(() => void dispatch(cancelProfitOrder({orderID})), [dispatch, orderID]);
+ const emitConfirm = React.useCallback(() => api.cancelProfitOrder({orderID}), [api, orderID]);
return (
diff --git a/client/src/components/modals/GeneralModal.tsx b/client/src/components/modals/GeneralModal.tsx
new file mode 100644
index 00000000..adb3a658
--- /dev/null
+++ b/client/src/components/modals/GeneralModal.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import {Modal} from 'components';
+
+interface Props {
+ title: string;
+ subtitle: string;
+ onConfirm: () => void;
+}
+
+export function GeneralModal({title, subtitle, onConfirm}: Props) {
+ return (
+
+ {subtitle}
+
+ );
+}
diff --git a/client/src/components/modals/index.tsx b/client/src/components/modals/index.tsx
index 3c1e8fd4..b22b5084 100644
--- a/client/src/components/modals/index.tsx
+++ b/client/src/components/modals/index.tsx
@@ -3,3 +3,5 @@ export * from './CancelProfitOrderModal';
export * from './CancelAllOrdersModal';
export * from './CancelAllProfitOrdersModal';
export * from './AddProfitOrderModal';
+export * from './GeneralModal';
+export * from './AddApiKeysModal';
diff --git a/client/src/containers/CrossOrder/CrossOrderContainer.spec.ts b/client/src/containers/CrossOrder/CrossOrderContainer.spec.ts
index e8004e2d..17cf0f03 100644
--- a/client/src/containers/CrossOrder/CrossOrderContainer.spec.ts
+++ b/client/src/containers/CrossOrder/CrossOrderContainer.spec.ts
@@ -2,16 +2,16 @@ import {CROSS_ORDER_CONTAINER} from 'data-test-ids';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
import {partialInstrument, updateInstrument} from 'tests/websocketData/instrument';
import CrossOrderContainer from './CrossOrderContainer';
-import {createRenderer} from 'tests/influnt';
+import {createMainRenderer} from 'tests/influnt';
import {getState, openWebsocket, sendWebsocketMessage, storeActions} from 'tests/helpers';
-import {createMockedStore} from 'tests/mockStore';
import {textOf, isDisabled, respond} from 'influnt';
import {forgeMarketOrder} from 'tests/responses';
+import {Exchange} from 'redux/modules/settings/types';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const forceRerender = () => {};
-const render = createRenderer(CrossOrderContainer, {extraArgs: () => createMockedStore({})});
+const render = createMainRenderer(CrossOrderContainer, {passProps: {exchange: Exchange.BitMeX}});
describe('CrossOrderContainer', () => {
it('should render submit button as disabled when not subscribed to ws', async () => {
@@ -39,7 +39,7 @@ describe('CrossOrderContainer', () => {
});
expect(result).toEqual({
- actions: ['REDUX_WEBSOCKET::OPEN', 'REDUX_WEBSOCKET::MESSAGE'],
+ actions: ['bitmex::OPEN', 'bitmex::MESSAGE'],
isDisabled: true,
submitButtonLabel: 'Place a crossunder-market sell order',
});
@@ -59,12 +59,7 @@ describe('CrossOrderContainer', () => {
.inspect({actions: storeActions(), cross: getState('cross')});
expect(result).toEqual({
- actions: [
- 'REDUX_WEBSOCKET::OPEN',
- 'REDUX_WEBSOCKET::MESSAGE',
- 'cross/CREATE_CROSS_ORDER',
- 'cross/ORDER_CROSSED_ONCE',
- ],
+ actions: ['bitmex::OPEN', 'bitmex::MESSAGE', 'cross/CREATE_CROSS_ORDER', 'cross/ORDER_CROSSED_ONCE'],
cross: {
crossOrderPrice: 10000,
crossOrderQuantity: 200,
@@ -95,10 +90,10 @@ describe('CrossOrderContainer', () => {
expect(result).toEqual({
actions: [
- 'REDUX_WEBSOCKET::OPEN',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::OPEN',
+ 'bitmex::MESSAGE',
'cross/CREATE_CROSS_ORDER',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::MESSAGE',
'cross/ORDER_CROSSED_ONCE',
'cross/CROSS_POST_MARKET_ORDER/pending',
'cross/CROSS_POST_MARKET_ORDER/fulfilled',
@@ -128,8 +123,8 @@ describe('CrossOrderContainer', () => {
expect(result).toEqual({
actions: [
- 'REDUX_WEBSOCKET::OPEN',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::OPEN',
+ 'bitmex::MESSAGE',
'cross/CREATE_CROSS_ORDER',
'cross/ORDER_CROSSED_ONCE',
'cross/CLEAR_CROSS_ORDER',
@@ -170,12 +165,12 @@ describe('CrossOrderContainer', () => {
expect(result).toEqual({
actions: [
- 'REDUX_WEBSOCKET::OPEN',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::OPEN',
+ 'bitmex::MESSAGE',
'cross/CREATE_CROSS_ORDER',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::MESSAGE',
'cross/ORDER_CROSSED_ONCE',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::MESSAGE',
'cross/CROSS_POST_MARKET_ORDER/pending',
'cross/CROSS_POST_MARKET_ORDER/fulfilled',
],
diff --git a/client/src/containers/CrossOrder/CrossOrderContainer.tsx b/client/src/containers/CrossOrder/CrossOrderContainer.tsx
index 8ba33a3a..7a2afcdc 100644
--- a/client/src/containers/CrossOrder/CrossOrderContainer.tsx
+++ b/client/src/containers/CrossOrder/CrossOrderContainer.tsx
@@ -4,11 +4,18 @@ import {SelectDropdown, InputField, Button, SideRadioButtons, Row, MainContainer
import {SYMBOL, SIDE} from 'redux/api/bitmex/types';
import {CROSS_ORDER_CONTAINER} from 'data-test-ids';
import buildOrderPresenter from '../../presenters/cross-label-presenter';
-import {clearCrossOrder, createCrossOrder} from 'redux/modules/cross/crossModule';
+import {clearCrossOrder} from 'redux/modules/cross/crossModule';
import {useHooks} from './useHooks';
import {INSTRUMENT_PARAMS} from 'utils';
+import {useAppContext} from 'general/hooks';
+import {Exchange} from 'redux/modules/settings/types';
-export default React.memo(function CrossOrderContainer() {
+interface Props {
+ exchange: Exchange;
+}
+
+export default React.memo(function CrossOrderContainer({exchange}: Props) {
+ const {api} = useAppContext();
const dispatch = useDispatch();
const [symbol, setSymbol] = React.useState(SYMBOL.XBTUSD);
@@ -16,15 +23,15 @@ export default React.memo(function CrossOrderContainer() {
const [quantity, setQuantity] = React.useState('');
const [side, setSide] = React.useState(SIDE.SELL);
- const {wsCrossPrice, connected, crossOrderPrice} = useHooks();
+ const {wsCrossPrice, connected, crossOrderPrice} = useHooks(exchange);
const createOrder = React.useCallback(() => {
if (price && +price > 0 && quantity && +quantity > 0) {
- dispatch(createCrossOrder({price: +price, symbol, side, orderQty: +quantity}));
+ api.createCrossOrder({price: +price, symbol, side, orderQty: +quantity});
setPrice('');
setQuantity('');
}
- }, [dispatch, price, quantity, side, symbol]);
+ }, [api, price, quantity, side, symbol]);
const cancelCrossOrder = React.useCallback(() => void dispatch(clearCrossOrder()), [dispatch]);
diff --git a/client/src/containers/CrossOrder/useHooks.ts b/client/src/containers/CrossOrder/useHooks.ts
index d9d1f5c5..d88162e6 100644
--- a/client/src/containers/CrossOrder/useHooks.ts
+++ b/client/src/containers/CrossOrder/useHooks.ts
@@ -1,18 +1,21 @@
import {useEffect} from 'react';
import {shallowEqual, useDispatch, useSelector} from 'react-redux';
import {AppState} from 'redux/modules/state';
-import {orderCrossedOnce, postMarketCrossOrder} from 'redux/modules/cross/crossModule';
+import {orderCrossedOnce} from 'redux/modules/cross/crossModule';
import {hasCrossedOnceSelector, hasCrossedSecondTimeSelector, websocketCrossPriceSelector} from 'redux/selectors';
+import {useAppContext} from 'general/hooks';
+import {Exchange} from 'redux/modules/settings/types';
-export function useHooks() {
+export function useHooks(exchange: Exchange) {
+ const {api} = useAppContext();
const {hasCrossedOnce, hasCrossedSecondTime, wsCrossPrice, connected, crossOrderPrice, hasPriceCrossedOnce} =
useSelector((state: AppState) => {
const {websocket, cross} = state;
return {
- hasCrossedOnce: hasCrossedOnceSelector(state),
- hasCrossedSecondTime: hasCrossedSecondTimeSelector(state),
- wsCrossPrice: websocketCrossPriceSelector(state),
- connected: websocket.connected,
+ hasCrossedOnce: hasCrossedOnceSelector(state, exchange),
+ hasCrossedSecondTime: hasCrossedSecondTimeSelector(state, exchange),
+ wsCrossPrice: websocketCrossPriceSelector(state, exchange),
+ connected: websocket[exchange].connected,
crossOrderPrice: cross.crossOrderPrice,
hasPriceCrossedOnce: cross.hasPriceCrossedOnce,
};
@@ -30,9 +33,9 @@ export function useHooks() {
useEffect(() => {
if (hasCrossedSecondTime) {
//@ts-expect-error
- dispatch(postMarketCrossOrder());
+ api.postMarketCrossOrder();
}
- }, [dispatch, hasCrossedSecondTime]);
+ }, [api, hasCrossedSecondTime]);
return {
wsCrossPrice,
diff --git a/client/src/containers/MarketOrder/MarketOrderContainer.spec.tsx b/client/src/containers/MarketOrder/MarketOrderContainer.spec.tsx
index 64abf8bf..39257bc8 100644
--- a/client/src/containers/MarketOrder/MarketOrderContainer.spec.tsx
+++ b/client/src/containers/MarketOrder/MarketOrderContainer.spec.tsx
@@ -2,12 +2,11 @@ import MarketOrderContainer from './MarketOrderContainer';
import {COMPONENTS, MARKET_CONTAINER} from 'data-test-ids';
import {forgeMarketOrder} from 'tests/responses';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
-import {createRenderer} from 'tests/influnt';
-import {createMockedStore} from 'tests/mockStore';
+import {createMainRenderer} from 'tests/influnt';
import {isDisabled, respond, exists} from 'influnt';
import {storeActions} from 'tests/helpers';
-const render = createRenderer(MarketOrderContainer, {extraArgs: () => createMockedStore({})});
+const render = createMainRenderer(MarketOrderContainer);
describe('MarketOrder', () => {
it('should disable market buy and market sell buttons by default', async () => {
diff --git a/client/src/containers/MarketOrder/MarketOrderContainer.tsx b/client/src/containers/MarketOrder/MarketOrderContainer.tsx
index 7b20b03d..bbdbb60c 100644
--- a/client/src/containers/MarketOrder/MarketOrderContainer.tsx
+++ b/client/src/containers/MarketOrder/MarketOrderContainer.tsx
@@ -1,30 +1,30 @@
import React from 'react';
import {WarningTwoIcon} from '@chakra-ui/icons';
-import {useDispatch} from 'react-redux';
-import {postMarketOrder} from 'redux/modules/preview/previewModule';
-import {useReduxSelector} from 'redux/helpers/hookHelpers';
import {SYMBOL, SIDE} from 'redux/api/bitmex/types';
import {MARKET_CONTAINER} from 'data-test-ids';
import {SelectDropdown, InputField, Button, Row, MainContainer} from 'components';
+import {useAppContext} from 'general/hooks';
+import {useSelector} from 'react-redux';
+import {AppState} from 'redux/modules/state';
const icons = [{element: WarningTwoIcon, color: 'red', onHoverMessage: 'Minimum lotsize for XBT is 100'}];
export default React.memo(function MarketOrderContainer() {
- const dispatch = useDispatch();
+ const {api} = useAppContext();
const [symbol, setSymbol] = React.useState(SYMBOL.XBTUSD);
const [quantity, setQuantity] = React.useState('');
- const {previewLoading} = useReduxSelector('previewLoading');
+ const loading = useSelector((state: AppState) => state.preview.previewLoading);
const submitMarketOrder = React.useCallback(
(id: SIDE) => {
if (quantity) {
- dispatch(postMarketOrder({symbol, orderQty: +quantity, side: id}));
+ api.postMarketOrder({symbol, orderQty: +quantity, side: id});
}
setQuantity('');
},
- [dispatch, symbol, quantity],
+ [symbol, quantity, api],
);
return (
@@ -41,7 +41,7 @@ export default React.memo(function MarketOrderContainer() {
id={SIDE.BUY}
label="MARKET Buy"
onClick={submitMarketOrder}
- isLoading={previewLoading}
+ isLoading={loading}
variant={SIDE.BUY}
disabled={!quantity || +quantity > 20e6}
/>
@@ -50,7 +50,7 @@ export default React.memo(function MarketOrderContainer() {
id={SIDE.SELL}
label="MARKET Sell"
onClick={submitMarketOrder}
- isLoading={previewLoading}
+ isLoading={loading}
variant={SIDE.SELL}
disabled={!quantity || +quantity > 20e6}
/>
diff --git a/client/src/containers/OpenOrders/OpenOrdersContainer.spec.ts b/client/src/containers/OpenOrders/OpenOrdersContainer.spec.ts
index 6d573e30..13996583 100644
--- a/client/src/containers/OpenOrders/OpenOrdersContainer.spec.ts
+++ b/client/src/containers/OpenOrders/OpenOrdersContainer.spec.ts
@@ -2,14 +2,13 @@ import {OpenOrdersContainer} from 'containers';
import {ADD_ORDER_MODAL, GLOBAL, OPEN_ORDERS_CONTAINER} from 'data-test-ids';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
import {builderProfitOrder, buildOrder} from 'tests/builders';
-import {createRenderer} from 'tests/influnt';
+import {createMainRenderer} from 'tests/influnt';
import {countOf, exists, respond} from 'influnt';
import {forgeOpenOrders, forgeOrderCancel, forgeOrderCancelAll, forgeProfitTargetOrder} from 'tests/responses';
-import {createMockedStore} from 'tests/mockStore';
import {createProfitTarget} from 'utils';
import {getState, storeActions} from 'tests/helpers';
-const render = createRenderer(OpenOrdersContainer, {extraArgs: () => createMockedStore()});
+const render = createMainRenderer(OpenOrdersContainer);
describe('OpenOrders', () => {
it('should show empty cta when there are no open orders', async () => {
@@ -56,15 +55,15 @@ describe('OpenOrders', () => {
});
it('should cancel an open order', async () => {
- const orderID1 = 'OrderID1';
+ const orderID = 'OrderID1';
const [getOpenOrdersPromise, orderCancelPromise] = [
- respond('getOpenOrders', [undefined]).with(forgeOpenOrders([buildOrder({orderID: orderID1}), buildOrder()])),
- respond('orderCancel', [{orderID: [orderID1]}]).with(forgeOrderCancel([{orderID: orderID1}])),
+ respond('getOpenOrders', [undefined]).with(forgeOpenOrders([buildOrder({orderID}), buildOrder()])),
+ respond('orderCancel', [{orderID: [orderID]}]).with(forgeOrderCancel([{orderID}])),
];
const result = await render({mocks: [getOpenOrdersPromise]})
- .press(`${OPEN_ORDERS_CONTAINER.CANCEL}.${orderID1}`)
+ .press(`${OPEN_ORDERS_CONTAINER.CANCEL}.${orderID}`)
.press(GLOBAL.MODAL_CONFIRM)
.inspect({orderRowCountBefore: countOf(OPEN_ORDERS_CONTAINER.ORDER_ROW)})
.resolve(orderCancelPromise)
@@ -81,7 +80,8 @@ describe('OpenOrders', () => {
'orders/CANCEL_ORDER/pending',
'orders/CANCEL_ORDER/fulfilled',
],
- network: [{getOpenOrders: [undefined]}, {orderCancel: [{orderID: ['OrderID1']}]}],
+ network: [{getOpenOrders: [undefined]}, {orderCancel: [{orderID: [orderID]}]}],
+ modal: [{showCancelOrder: {orderID}}],
emptyCtaVisible: false,
orderRowCountBefore: 2,
orderRowCountAfter: 1,
@@ -123,6 +123,7 @@ describe('OpenOrders', () => {
'orders/CANCEL_ALL_ORDERS/pending',
'orders/CANCEL_ALL_ORDERS/fulfilled',
],
+ modal: [{showCancelAllOrders: {totalOrders: 2}}],
emptyCtaVisible: true,
orderRowCountBefore: 1,
orderRowCountAfter: 0,
@@ -135,11 +136,10 @@ describe('OpenOrders', () => {
const profitOrderID1 = 'ProfitOrderID1';
const order = buildOrder({orderID: orderID1});
+ const profitOrder = builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderID1});
const [getOpenOrdersPromise, orderCancelPromise] = [
- respond('getOpenOrders', [undefined]).with(
- forgeOpenOrders([order, builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderID1})]),
- ),
+ respond('getOpenOrders', [undefined]).with(forgeOpenOrders([order, profitOrder])),
respond('orderCancel', [{orderID: profitOrderID1}]).with(forgeOrderCancel([{orderID: profitOrderID1}])),
];
@@ -167,6 +167,17 @@ describe('OpenOrders', () => {
emptyCtaVisible: false,
orderRowCountAfter: 1,
orderRowCountBefore: 1,
+ modal: [
+ {
+ showCancelProfitOrder: {
+ orderID: 'ProfitOrderID1',
+ price: profitOrder.price,
+ quantity: profitOrder.orderQty,
+ side: profitOrder.side,
+ symbol: profitOrder.symbol,
+ },
+ },
+ ],
orders: {
openOrders: [order],
ordersError: '',
@@ -179,7 +190,7 @@ describe('OpenOrders', () => {
it('should cancel all profit orders of one of the open order`s', async () => {
const orderID1 = 'OrderID1';
- const profitOrderIDs = ['ProfitOrderID1', 'ProfitOrderID2', 'ProfitOrderID3'];
+ const profitOrderIds = ['ProfitOrderID1', 'ProfitOrderID2', 'ProfitOrderID3'];
const order = buildOrder({orderID: orderID1});
@@ -187,12 +198,12 @@ describe('OpenOrders', () => {
respond('getOpenOrders', [undefined]).with(
forgeOpenOrders([
order,
- builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderIDs[0]}),
- builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderIDs[1]}),
- builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderIDs[2]}),
+ builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderIds[0]}),
+ builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderIds[1]}),
+ builderProfitOrder({orderID: orderID1, profitOrderID: profitOrderIds[2]}),
]),
),
- respond('orderCancel', [{orderID: profitOrderIDs}]).with(forgeOrderCancel([{orderID: profitOrderIDs}])),
+ respond('orderCancel', [{orderID: profitOrderIds}]).with(forgeOrderCancel([{orderID: profitOrderIds}])),
];
const result = await render({mocks: [getOpenOrdersPromise]})
@@ -215,10 +226,11 @@ describe('OpenOrders', () => {
'orders/CANCEL_ALL_PROFIT_ORDERS/pending',
'orders/CANCEL_ALL_PROFIT_ORDERS/fulfilled',
],
- network: [{getOpenOrders: [undefined]}, {orderCancel: [{orderID: profitOrderIDs}]}],
+ network: [{getOpenOrders: [undefined]}, {orderCancel: [{orderID: profitOrderIds}]}],
emptyCtaVisible: false,
orderRowCountAfter: 1,
orderRowCountBefore: 1,
+ modal: [{showCancelAllProfitOrders: {profitOrderIds, totalOrders: 3}}],
orders: {
openOrders: [order],
ordersError: '',
@@ -264,6 +276,7 @@ describe('OpenOrders', () => {
'orders/CANCEL_ORDER/pending',
'orders/CANCEL_ORDER/fulfilled',
],
+ modal: [{showCancelOrder: {orderID: 'OrderID1'}}],
network: [{getOpenOrders: [undefined]}, {orderCancel: [{orderID: ['OrderID1', 'ProfitOrderID1']}]}],
emptyCtaVisible: true,
orderRowCountAfter: 0,
@@ -324,6 +337,7 @@ describe('OpenOrders', () => {
],
},
],
+ modal: [{showAddProfitTarget: {orderID: 'OrderID1'}}],
emptyCtaVisible: false,
orderRowCountAfter: 1,
orderRowCountBefore: 1,
@@ -402,6 +416,7 @@ describe('OpenOrders', () => {
],
},
],
+ modal: [{showAddProfitTarget: {orderID: 'OrderID1'}}],
emptyCtaVisible: false,
orderRowCountAfter: 1,
orderRowCountBefore: 1,
diff --git a/client/src/containers/OpenOrders/OpenOrdersContainer.tsx b/client/src/containers/OpenOrders/OpenOrdersContainer.tsx
index 36d3da42..aff5c4ce 100644
--- a/client/src/containers/OpenOrders/OpenOrdersContainer.tsx
+++ b/client/src/containers/OpenOrders/OpenOrdersContainer.tsx
@@ -1,16 +1,16 @@
import React from 'react';
import {Tbody, Th, Thead, Tr, Table, Box} from '@chakra-ui/react';
import {RepeatIcon} from '@chakra-ui/icons';
-import {useDispatch} from 'react-redux';
-import {useReduxSelector} from 'redux/helpers/hookHelpers';
import {formatPrice} from 'general/formatting';
import {Order, ORD_TYPE} from 'redux/api/bitmex/types';
import {MainContainer} from 'components';
-import {getOpenOrders} from 'redux/modules/orders/ordersModule';
-import {useModal} from 'general/hooks';
+import {useAppContext, useModal} from 'general/hooks';
import {OPEN_ORDERS_CONTAINER} from 'data-test-ids';
import OpenOrderRow from './OpenOrderRow';
import ProfitOrderInActionRow from './ProfitOrderInActionRow';
+import {useSelector} from 'react-redux';
+import {AppState} from 'redux/modules/state';
+import {groupedOrdersSelector} from 'redux/selectors';
function Text({children}: {children: React.ReactNode}) {
return (
@@ -47,24 +47,20 @@ export const presentOrderPrice = (order: Order) => {
}
};
-export default function OpenOrdersContainer() {
- const dispatch = useDispatch();
+export default React.memo(function OpenOrdersContainer() {
+ const {api} = useAppContext();
const {modals} = useModal();
- const {openOrders, profitOrders, profitOrdersInAction, groupedOrders, ordersLoading, ordersError} = useReduxSelector(
- 'openOrders',
- 'profitOrders',
- 'profitOrdersInAction',
- 'groupedOrders',
- 'ordersLoading',
- 'ordersError',
- );
-
- const fetchOpenOrders = React.useCallback(() => void dispatch(getOpenOrders()), [dispatch]);
+ const openOrders = useSelector((state: AppState) => state.orders.openOrders);
+ const profitOrders = useSelector((state: AppState) => state.orders.profitOrders);
+ const profitOrdersInAction = useSelector((state: AppState) => state.orders.profitOrdersInAction);
+ const ordersLoading = useSelector((state: AppState) => state.orders.ordersLoading);
+ const ordersError = useSelector((state: AppState) => state.orders.ordersError);
+ const groupedOrders = useSelector(groupedOrdersSelector);
React.useEffect(() => {
- fetchOpenOrders();
- }, [fetchOpenOrders]);
+ api.getOpenOrders();
+ }, [api]);
const showCancelAllOrdersModal = React.useCallback(() => {
const totalOrders = openOrders.length + profitOrders.length + profitOrdersInAction.length;
@@ -85,8 +81,8 @@ export default function OpenOrdersContainer() {
}, [ordersError, ordersLoading, openOrders, profitOrdersInAction]);
const icons = React.useMemo(
- () => [{element: RepeatIcon, onClick: !ordersLoading ? fetchOpenOrders : undefined, color: 'green'}],
- [fetchOpenOrders, ordersLoading],
+ () => [{element: RepeatIcon, onClick: !ordersLoading ? api.getOpenOrders : undefined, color: 'green'}],
+ [api, ordersLoading],
);
return (
@@ -126,4 +122,4 @@ export default function OpenOrdersContainer() {
);
-}
+});
diff --git a/client/src/containers/ScaledOrders/OrdersPreviewTable/OrdersPreviewTable.tsx b/client/src/containers/ScaledOrders/OrdersPreviewTable/OrdersPreviewTable.tsx
index 9e9324b9..1eae0cec 100644
--- a/client/src/containers/ScaledOrders/OrdersPreviewTable/OrdersPreviewTable.tsx
+++ b/client/src/containers/ScaledOrders/OrdersPreviewTable/OrdersPreviewTable.tsx
@@ -4,11 +4,12 @@ import OrdersTable from './orders-table';
import DetailsTable from './details-table';
import styles from './OrdersPreviewTable.module.scss';
import {SCALED_CONTAINER} from 'data-test-ids';
-import {useReduxSelector} from 'redux/helpers/hookHelpers';
import {SYMBOL} from 'redux/api/bitmex/types';
+import {useSelector} from 'react-redux';
+import {AppState} from 'redux/modules/state';
export default function OrdersPreviewTable() {
- const {orders} = useReduxSelector('orders');
+ const orders = useSelector((state: AppState) => state.preview.orders);
return (
{
return (
diff --git a/client/src/containers/ScaledOrders/ScaledOrders.spec.ts b/client/src/containers/ScaledOrders/ScaledOrders.spec.ts
index 5b92d8f5..e36cf415 100644
--- a/client/src/containers/ScaledOrders/ScaledOrders.spec.ts
+++ b/client/src/containers/ScaledOrders/ScaledOrders.spec.ts
@@ -3,8 +3,7 @@ import {COMPONENTS, SCALED_CONTAINER} from 'data-test-ids';
import {createScaledOrders, DISTRIBUTION} from 'utils';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
import {forgeResult} from 'tests/responses';
-import {createRenderer} from 'tests/influnt';
-import {createMockedStore} from 'tests/mockStore';
+import {createMainRenderer} from 'tests/influnt';
import {InfluntEngine, respond, isDisabled, exists, countOf} from 'influnt';
import {storeActions} from 'tests/helpers';
@@ -30,7 +29,7 @@ function fillInputs({orderQty, n_tp, start, end, stop, symbol, side}: ScaledInpu
};
}
-const render = createRenderer(ScaledContainer, {extraArgs: () => createMockedStore()});
+const render = createMainRenderer(ScaledContainer);
describe('ScaledOrders', () => {
it('should render submit button as disabled', async () => {
@@ -42,7 +41,7 @@ describe('ScaledOrders', () => {
it('should submit sell scaled orders without stoploss', async () => {
const input = {orderQty: 1000, n_tp: 2, start: 1000, end: 2000, side: SIDE.SELL, symbol: SYMBOL.XBTUSD, stop: 0};
const orders = createScaledOrders({ordersProps: input, distribution: DISTRIBUTION.Uniform});
- const promise = respond('orderBulk', [orders]).with(forgeResult(orders));
+ const promise = respond('orderBulk', [{orders}]).with(forgeResult(orders));
const result = await render()
.apply(fillInputs(input))
@@ -53,7 +52,7 @@ describe('ScaledOrders', () => {
expect(result).toEqual({
actions: ['preview/PREVIEW_POST_ORDER/pending', 'preview/PREVIEW_POST_ORDER/fulfilled'],
- network: [{orderBulk: [orders]}],
+ network: [{orderBulk: [{orders}]}],
spinnerVisible: true,
toast: [{message: 'Submitted Scaled Orders', toastPreset: 'success'}],
});
@@ -62,7 +61,7 @@ describe('ScaledOrders', () => {
it('should submit buy scaled orders without stoploss', async () => {
const input = {orderQty: 1000, n_tp: 2, start: 1000, end: 2000, side: SIDE.BUY, symbol: SYMBOL.XBTUSD, stop: 0};
const orders = createScaledOrders({ordersProps: input, distribution: DISTRIBUTION.Uniform});
- const promise = respond('orderBulk', [orders]).with(forgeResult(orders));
+ const promise = respond('orderBulk', [{orders}]).with(forgeResult(orders));
const result = await render()
.apply(fillInputs(input))
@@ -73,7 +72,7 @@ describe('ScaledOrders', () => {
expect(result).toEqual({
actions: ['preview/PREVIEW_POST_ORDER/pending', 'preview/PREVIEW_POST_ORDER/fulfilled'],
- network: [{orderBulk: [orders]}],
+ network: [{orderBulk: [{orders}]}],
spinnerVisible: true,
toast: [{message: 'Submitted Scaled Orders', toastPreset: 'success'}],
});
@@ -90,7 +89,7 @@ describe('ScaledOrders', () => {
stop: 3000,
};
const orders = createScaledOrders({ordersProps: input, distribution: DISTRIBUTION.Uniform});
- const promise = respond('orderBulk', [orders]).with(forgeResult(orders));
+ const promise = respond('orderBulk', [{orders}]).with(forgeResult(orders));
const result = await render()
.apply(fillInputs({orderQty: 1000, n_tp: 2, start: 1000, end: 20, stop: 3000}))
@@ -101,7 +100,7 @@ describe('ScaledOrders', () => {
expect(result).toEqual({
actions: ['preview/PREVIEW_POST_ORDER/pending', 'preview/PREVIEW_POST_ORDER/fulfilled'],
- network: [{orderBulk: [orders]}],
+ network: [{orderBulk: [{orders}]}],
spinnerVisible: true,
toast: [{message: 'Submitted Scaled Orders', toastPreset: 'success'}],
});
diff --git a/client/src/containers/ScaledOrders/ScaledOrders.tsx b/client/src/containers/ScaledOrders/ScaledOrders.tsx
index 876dfeb8..05264a84 100644
--- a/client/src/containers/ScaledOrders/ScaledOrders.tsx
+++ b/client/src/containers/ScaledOrders/ScaledOrders.tsx
@@ -1,15 +1,16 @@
import React from 'react';
-import {useDispatch} from 'react-redux';
+import {useDispatch, useSelector} from 'react-redux';
import {Box, Tooltip} from '@chakra-ui/react';
import {WarningTwoIcon, WarningIcon} from '@chakra-ui/icons';
import OrdersPreviewTable from './OrdersPreviewTable/OrdersPreviewTable';
-import {previewOrders, previewToggle, postOrderBulk} from 'redux/modules/preview/previewModule';
+import {previewOrders, previewToggle} from 'redux/modules/preview/previewModule';
import {InputField, SelectDropdown, MainContainer, Button, SideRadioButtons, Row} from 'components';
import DistributionsRadioGroup from './DistributionsRadioGroup';
import {createScaledOrders, DISTRIBUTION, INSTRUMENT_PARAMS} from 'utils';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
import {SCALED_CONTAINER} from 'data-test-ids';
-import {useReduxSelector} from 'redux/helpers/hookHelpers';
+import {useAppContext} from 'general/hooks';
+import {AppState} from 'redux/modules/state';
const icons = [{element: WarningTwoIcon, color: 'red', onHoverMessage: 'Minimum lotsize for XBT is 100'}];
@@ -36,8 +37,10 @@ const initialState: Readonly = {
};
export default React.memo(function ScaledContainer() {
+ const {api} = useAppContext();
const dispatch = useDispatch();
- const {showPreview, previewLoading} = useReduxSelector('showPreview', 'previewLoading');
+ const showPreview = useSelector((state: AppState) => state.preview.showPreview);
+ const previewLoading = useSelector((state: AppState) => state.preview.previewLoading);
const [state, setState] = React.useState(initialState);
const [isDirty, setDirty] = React.useState(false);
@@ -65,10 +68,10 @@ export default React.memo(function ScaledContainer() {
const onOrderSubmit = React.useCallback((): void => {
const {distribution, ...rest} = state as RequiredProperty;
const ordersProps = {...rest, start: +rest.start, end: +rest.end, stop: rest.stop != undefined ? +rest.stop : 0};
- dispatch(postOrderBulk(createScaledOrders({ordersProps, distribution})));
+ api.postOrderBulk({orders: createScaledOrders({ordersProps, distribution})});
setState(initialState);
setDirty(true);
- }, [dispatch, state]);
+ }, [api, state]);
const onPreviewOrders = React.useCallback((): void => {
if (!isDirty) {
diff --git a/client/src/containers/TickerPrices/TickerPricesContainer.tsx b/client/src/containers/TickerPrices/TickerPricesContainer.tsx
index dc742096..c93f6cb7 100644
--- a/client/src/containers/TickerPrices/TickerPricesContainer.tsx
+++ b/client/src/containers/TickerPrices/TickerPricesContainer.tsx
@@ -8,6 +8,7 @@ import {MainContainer, Row} from 'components';
import styles from './TickerPricesContainer.module.scss';
import {AppState} from 'redux/modules/state';
import {formatPrice} from 'general/formatting';
+import {useExchange} from 'general/hooks';
const none = '---' as unknown as number;
@@ -18,8 +19,9 @@ const defaultData: SymbolPrices[] = [
];
export default React.memo(function TickerPricesContainer() {
- const wsMessage = useSelector((state: AppState) => state.websocket.message);
- const allPrices = useSelector(allWebsocketBidAskPrices, isEqual);
+ const exchange = useExchange();
+ const wsMessage = useSelector((state: AppState) => state.websocket[exchange].message);
+ const allPrices = useSelector((state: AppState) => allWebsocketBidAskPrices(state, exchange), isEqual);
const data = allPrices?.length ? allPrices : defaultData;
diff --git a/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.spec.ts b/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.spec.ts
index e031d9b5..a9a7fd2b 100644
--- a/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.spec.ts
+++ b/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.spec.ts
@@ -6,13 +6,15 @@ import {partialInstrument, updateInstrument} from 'tests/websocketData/instrumen
import {partialOrder} from 'tests/websocketData/order';
import {forgeAmendOrder, forgeLimitOrder} from 'tests/responses';
import {getState, openWebsocket, sendWebsocketMessage, storeActions} from 'tests/helpers';
-import {createRenderer} from 'tests/influnt';
+import {createMainRenderer} from 'tests/influnt';
import {textOf, isDisabled, respond} from 'influnt';
import {createMockedStore} from 'tests/mockStore';
+import {createMemoryHistory} from 'history';
+import {Exchange} from 'redux/modules/settings/types';
const orderID = 'OrderId';
-const render = createRenderer(TrailingLimitOrderContainer, {extraArgs: () => createMockedStore()});
+const render = createMainRenderer(TrailingLimitOrderContainer, {passProps: {exchange: Exchange.BitMeX}});
describe('TrailingLimitContainer', () => {
const commonOrder = ({orderQty, price}: {orderQty: number; price: number}) => ({
@@ -48,7 +50,7 @@ describe('TrailingLimitContainer', () => {
});
expect(result).toEqual({
- actions: ['REDUX_WEBSOCKET::OPEN', 'REDUX_WEBSOCKET::MESSAGE'],
+ actions: ['bitmex::OPEN', 'bitmex::MESSAGE'],
isDisabled: true,
submitButtonLabel: 'Submit order at 10,322.0',
});
@@ -64,7 +66,21 @@ describe('TrailingLimitContainer', () => {
instrument: [{symbol: SYMBOL.XBTUSD, askPrice: 501, bidPrice: 500.5}],
});
- const result = await render({extraArgs: createMockedStore({websocket})})
+ const result = await render({
+ extraArgs: {
+ store: createMockedStore({
+ websocket,
+ settings: {
+ activeExchange: Exchange.BitMeX,
+ activeApiKeys: {bitmex: true, bitmexTEST: false},
+ settingsLoading: false,
+ settingsError: '',
+ getAllApiKeysLoading: false,
+ },
+ }),
+ history: createMemoryHistory(),
+ },
+ })
.inputText(TRAILING_LIMIT_CONTAINER.QUANTITY_INPUT, '200')
.press(TRAILING_LIMIT_CONTAINER.SUBMIT_TRAILING_ORDER)
.resolve(mock)
@@ -72,11 +88,7 @@ describe('TrailingLimitContainer', () => {
.inspect({actions: storeActions(), trailing: getState('trailing')});
expect(result).toEqual({
- actions: [
- 'trailing/POST_TRAILING_ORDER/pending',
- 'trailing/POST_TRAILING_ORDER/fulfilled',
- 'REDUX_WEBSOCKET::MESSAGE',
- ],
+ actions: ['trailing/POST_TRAILING_ORDER/pending', 'trailing/POST_TRAILING_ORDER/fulfilled', 'bitmex::MESSAGE'],
network: [{limitOrder: [{orderQty: 200, price: 501, side: 'Sell', symbol: 'XBTUSD', text: 'best_order'}]}],
toast: [{message: 'Trailing Order placed at 501', toastPreset: 'success'}],
trailing: {
@@ -107,11 +119,11 @@ describe('TrailingLimitContainer', () => {
expect(result).toEqual({
actions: [
- 'REDUX_WEBSOCKET::OPEN',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::OPEN',
+ 'bitmex::MESSAGE',
'trailing/POST_TRAILING_ORDER/pending',
'trailing/POST_TRAILING_ORDER/fulfilled',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::MESSAGE',
'trailing/__CLEAR_TRAILING_ORDER',
],
network: [{limitOrder: [{orderQty: 200, price: 10322, side: 'Sell', symbol: 'XBTUSD', text: 'best_order'}]}],
@@ -149,12 +161,12 @@ describe('TrailingLimitContainer', () => {
expect(result).toEqual({
actions: [
- 'REDUX_WEBSOCKET::OPEN',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::OPEN',
+ 'bitmex::MESSAGE',
'trailing/POST_TRAILING_ORDER/pending',
'trailing/POST_TRAILING_ORDER/fulfilled',
- 'REDUX_WEBSOCKET::MESSAGE',
- 'REDUX_WEBSOCKET::MESSAGE',
+ 'bitmex::MESSAGE',
+ 'bitmex::MESSAGE',
'trailing/PUT_TRAILING_ORDER/pending',
'trailing/PUT_TRAILING_ORDER/fulfilled',
],
diff --git a/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.tsx b/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.tsx
index dd3fa581..c234231f 100644
--- a/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.tsx
+++ b/client/src/containers/TrailingLimitOrder/TrailingLimitOrderContainer.tsx
@@ -2,25 +2,32 @@ import React from 'react';
import {useDispatch} from 'react-redux';
import {Text} from '@chakra-ui/react';
import {WarningTwoIcon} from '@chakra-ui/icons';
-import {postTrailingOrder, cancelTrailingOrder, changeTrailingOrderSymbol} from 'redux/modules/trailing/trailingModule';
+import {changeTrailingOrderSymbol} from 'redux/modules/trailing/trailingModule';
import {SYMBOL, SIDE} from 'redux/api/bitmex/types';
import {SelectDropdown, InputField, Button, SideRadioButtons, Row, MainContainer} from 'components';
import {TRAILING_LIMIT_CONTAINER} from 'data-test-ids';
import buildOrderPresenter from '../../presenters/trailing-label-presenter';
import {useHooks} from './useHooks';
import {INSTRUMENT_PARAMS} from 'utils';
+import {useAppContext} from 'general/hooks';
+import {Exchange} from 'redux/modules/settings/types';
const icons = [{element: WarningTwoIcon, color: 'red', onHoverMessage: 'Minimum lotsize for XBT is 100'}];
-export default React.memo(function TrailingLimitOrderContainer() {
+interface Props {
+ exchange: Exchange;
+}
+
+export default React.memo(function TrailingLimitOrderContainer({exchange}: Props) {
const dispatch = useDispatch();
+ const {api} = useAppContext();
const [symbol, setSymbol] = React.useState(SYMBOL.XBTUSD);
const [side, setSide] = React.useState(SIDE.SELL);
const [quantity, setQuantity] = React.useState('');
const {wsCurrentPrice, wsBidAskPrices, trailOrderId, trailOrderStatus, trailOrderPrice, status, connected} =
- useHooks();
+ useHooks(exchange);
const spread = 1 / INSTRUMENT_PARAMS[symbol].ticksize;
const trailingOrderPrice =
@@ -28,13 +35,12 @@ export default React.memo(function TrailingLimitOrderContainer() {
const submitTrailingOrder = React.useCallback(() => {
if (trailingOrderPrice && quantity) {
- const payload = {symbol, side, orderQty: +quantity, price: trailingOrderPrice, text: 'best_order'};
- dispatch(postTrailingOrder(payload));
+ api.postTrailingOrder({symbol, side, orderQty: +quantity, price: trailingOrderPrice, text: 'best_order'});
setQuantity('');
}
- }, [dispatch, trailingOrderPrice, quantity, side, symbol]);
+ }, [api, trailingOrderPrice, quantity, side, symbol]);
- const cancelOrder = React.useCallback(() => void dispatch(cancelTrailingOrder({} as any)), [dispatch]);
+ const cancelOrder = React.useCallback(() => void api.cancelTrailingOrder({} as any), [api]);
const toggleInstrument = React.useCallback(
(symbol: SYMBOL) => {
diff --git a/client/src/containers/TrailingLimitOrder/useHooks.ts b/client/src/containers/TrailingLimitOrder/useHooks.ts
index a258a415..b004edf5 100644
--- a/client/src/containers/TrailingLimitOrder/useHooks.ts
+++ b/client/src/containers/TrailingLimitOrder/useHooks.ts
@@ -7,9 +7,12 @@ import {
websocketCurrentPrice,
websocketTrailingPriceSelector,
} from 'redux/selectors';
-import {ammendTrailingOrder, __clearTrailingOrder} from 'redux/modules/trailing/trailingModule';
+import {__clearTrailingOrder} from 'redux/modules/trailing/trailingModule';
+import {useAppContext} from 'general/hooks';
+import {Exchange} from 'redux/modules/settings/types';
-export function useHooks() {
+export function useHooks(exchange: Exchange) {
+ const {api} = useAppContext();
const {
wsTrailingPrice,
wsCurrentPrice,
@@ -23,11 +26,11 @@ export function useHooks() {
} = useSelector((state: AppState) => {
const {websocket, trailing} = state;
return {
- wsCurrentPrice: websocketCurrentPrice(state),
- wsTrailingPrice: websocketTrailingPriceSelector(state),
- wsBidAskPrices: websocketBidAskPrices(state),
- status: trailingOrderStatusSelector(state),
- connected: websocket.connected,
+ wsCurrentPrice: websocketCurrentPrice(state, exchange),
+ wsTrailingPrice: websocketTrailingPriceSelector(state, exchange),
+ wsBidAskPrices: websocketBidAskPrices(state, exchange),
+ status: trailingOrderStatusSelector(state, exchange),
+ connected: websocket[exchange].connected,
trailOrderId: trailing.trailOrderId,
trailOrderPrice: trailing.trailOrderPrice,
trailOrderStatus: trailing.trailOrderStatus,
@@ -42,10 +45,10 @@ export function useHooks() {
if (wsTrailingPrice && trailOrderPrice && !statuses.includes(status)) {
const toAmmend = wsTrailingPrice !== trailOrderPrice;
if (toAmmend) {
- dispatch(ammendTrailingOrder({orderID: trailOrderId, price: wsTrailingPrice}));
+ api.ammendTrailingOrder({orderID: trailOrderId, price: wsTrailingPrice});
}
}
- }, [dispatch, trailOrderPrice, trailOrderId, trailOrderSide, status, wsTrailingPrice]);
+ }, [api, trailOrderPrice, trailOrderId, trailOrderSide, status, wsTrailingPrice]);
useEffect(() => {
const statuses = ['Filled', 'Canceled', 'Order not placed.'];
diff --git a/client/src/context/app-context.tsx b/client/src/context/app-context.tsx
new file mode 100644
index 00000000..05d04567
--- /dev/null
+++ b/client/src/context/app-context.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import {useAppDispatch} from 'redux/store';
+import {Exchange} from 'redux/modules/settings/types';
+import * as ordersModule from 'redux/modules/orders/ordersModule';
+import * as previewModule from 'redux/modules/preview/previewModule';
+import * as trailingModule from 'redux/modules/trailing/trailingModule';
+import * as crossModule from 'redux/modules/cross/crossModule';
+
+const modules = {...ordersModule, ...previewModule, ...trailingModule, ...crossModule};
+
+type ReduxModules = typeof modules;
+
+type ApiActions = {
+ [key in keyof ReduxModules]: (
+ params: ReduxModules[key] extends (...args: any) => any
+ ? Omit[number], 'exchange'> extends {exchange?: Exchange}
+ ? void
+ : Omit[number], 'exchange'>
+ : void,
+ ) => void;
+};
+
+export interface AppContext {
+ api: ApiActions;
+}
+
+const initialContext = {
+ api: undefined,
+} as unknown as AppContext;
+
+export const AppContext = React.createContext(initialContext);
+
+export const AppProvider = React.memo(({children}: {children: React.ReactNode}) => {
+ const dispatch = useAppDispatch();
+
+ const context: AppContext = React.useMemo(
+ () => ({
+ api: new Proxy(modules, {
+ get(target: ReduxModules, key: keyof ReduxModules) {
+ if (typeof target[key] === 'function') {
+ return (params: any) => {
+ //@ts-ignore
+ dispatch(target[key](params));
+ };
+ }
+ return undefined;
+ },
+ }) as unknown as ApiActions,
+ }),
+ [dispatch],
+ );
+
+ return {children} ;
+});
diff --git a/client/src/context/registerModals.ts b/client/src/context/registerModals.ts
index d37dc01c..56dab6e4 100644
--- a/client/src/context/registerModals.ts
+++ b/client/src/context/registerModals.ts
@@ -5,6 +5,8 @@ import {
CancelProfitOrderModal,
CancelAllProfitOrdersModal,
AddProfitOrderModal,
+ GeneralModal,
+ AddApiKeysModal,
} from 'components/modals';
export type ShowModalArgs = {
@@ -23,6 +25,8 @@ const registeredModals = {
showCancelAllOrders: CancelAllOrdersModal,
showCancelAllProfitOrders: CancelAllProfitOrdersModal,
showAddProfitTarget: AddProfitOrderModal,
+ showGeneralModal: GeneralModal,
+ showAddApiKeys: AddApiKeysModal,
};
export function showRegisteredModal(type: keyof RegisteredModals, modalProps: P) {
@@ -33,7 +37,7 @@ export function createModals(showModal: ({type, props}: ShowModalArgs) => void):
return Object.assign(
{},
...Object.keys(registeredModals).map((modalName) => ({
- [modalName]: (props: any) => showModal({type: modalName as any, props}),
+ [modalName]: (props: any) => showModal({type: modalName as keyof typeof registeredModals, props}),
})),
);
}
diff --git a/client/src/data-test-ids.ts b/client/src/data-test-ids.ts
index c0bea201..9f8ebc87 100644
--- a/client/src/data-test-ids.ts
+++ b/client/src/data-test-ids.ts
@@ -35,6 +35,11 @@ export const OPEN_ORDERS_CONTAINER = {
ADD_PROFIT: 'OpenOrdersContainer.addProfit',
};
+export const ADD_API_KEYS_MODAL = {
+ API_KEY: 'AddApiKeysModal.ApiKey',
+ API_SECRET: 'AddApiKeysModal.ApiSecret',
+};
+
export const ADD_ORDER_MODAL = {
PRICE: 'AddOrderModal.price',
QUANTITY: 'AddOrderModal.quantity',
@@ -60,6 +65,16 @@ export const CROSS_ORDER_CONTAINER = {
SIDE: 'CrossOrderContainer.side',
};
+export const SETTINGS = {
+ API_KEY_ROW_STATUS: 'Settings.ApiKeyRowStatus',
+ API_KEY_ROW: 'Settings.ApiKeyRow',
+};
+
+export const HOME = {
+ ROW: 'Home.Row',
+ ICON: 'Home.Icon',
+};
+
export const GLOBAL = {
SNACKBAR: 'Global.SnackBar',
TOAST: 'Global.toast',
diff --git a/client/src/general/hooks.ts b/client/src/general/hooks.ts
index b80a3072..e142172d 100644
--- a/client/src/general/hooks.ts
+++ b/client/src/general/hooks.ts
@@ -1,8 +1,22 @@
import React from 'react';
import {ModalContext} from 'context/modal-context';
+import {useLocation} from 'react-router-dom';
+import {Exchange} from 'redux/modules/settings/types';
+import {AppContext} from 'context/app-context';
export function useModal() {
const context = React.useContext(ModalContext);
if (!context) throw new Error('Modal context: add wrapper');
return context;
}
+
+export function useAppContext() {
+ const context = React.useContext(AppContext);
+ if (!context) throw new Error('App context: add wrapper');
+ return context;
+}
+
+export function useExchange(): Exchange {
+ const location = useLocation();
+ return location.pathname.slice(1) as Exchange;
+}
diff --git a/client/src/index.tsx b/client/src/index.tsx
index 737ec54a..41edda34 100644
--- a/client/src/index.tsx
+++ b/client/src/index.tsx
@@ -1,11 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
+import {BrowserRouter} from 'react-router-dom';
import {Provider} from 'react-redux';
import {ChakraProvider, extendTheme} from '@chakra-ui/react';
import {createStore} from 'redux/store';
import App from './App';
import {ModalProvider} from 'context/modal-context';
-
+import {AppProvider} from 'context/app-context';
import * as serviceWorker from './serviceWorker';
import '../node_modules/bootstrap/dist/css/bootstrap.min.css';
@@ -108,11 +109,15 @@ const theme = extendTheme({
ReactDOM.render(
-
-
-
-
-
+
+
+
+
+
+
+
+
+
,
document.getElementById('root') as HTMLElement,
);
diff --git a/client/src/pages/Bitmex.tsx b/client/src/pages/Bitmex.tsx
new file mode 100644
index 00000000..95fb7373
--- /dev/null
+++ b/client/src/pages/Bitmex.tsx
@@ -0,0 +1,66 @@
+import 'scss/root.module.scss';
+import React from 'react';
+import {useDispatch, useSelector} from 'react-redux';
+import {Box} from '@chakra-ui/react';
+import {
+ ScaledOrders,
+ MarketOrderContainer,
+ TrailingLimitOrder,
+ TickerPricesContainer,
+ // CrossOrderContainer,
+ OpenOrdersContainer,
+} from 'containers';
+import {Spinner, ToastContainer} from 'components';
+import {useReduxSelector} from 'redux/helpers/hookHelpers';
+import {wsConnect, wsDisconnect, wsSubscribeTo, wsAuthenticate} from 'redux/modules/websocket/websocketModule';
+import {useAppContext} from 'general/hooks';
+import {Banner} from 'components/Banner/Banner';
+import {Exchange} from 'redux/modules/settings/types';
+import {activeApiKeySelector} from 'redux/selectors';
+
+const exchange = Exchange.BitMeX;
+
+const BitmexExchange = React.memo(() => {
+ const {api} = useAppContext();
+ const isApiKeyActive = useSelector(activeApiKeySelector);
+ const dispatch = useDispatch();
+ const {previewLoading, trailLoading, wsLoading, connected} = useReduxSelector(
+ exchange,
+ 'previewLoading',
+ 'trailLoading',
+ 'wsLoading',
+ 'connected',
+ );
+
+ React.useEffect(() => {
+ dispatch(wsConnect(exchange));
+
+ return () => {
+ dispatch(wsDisconnect(exchange));
+ };
+ }, [dispatch]);
+
+ React.useEffect(() => {
+ if (connected && isApiKeyActive) {
+ api.getBalance();
+ dispatch(wsAuthenticate());
+ dispatch(wsSubscribeTo('order'));
+ }
+ }, [dispatch, api, connected, isApiKeyActive]);
+
+ return (
+
+ {!isApiKeyActive && }
+
+
+
+
+ {/* TODO: disabling for now */}
+
+
+
+
+ );
+});
+
+export default BitmexExchange;
diff --git a/client/src/pages/Home.spec.tsx b/client/src/pages/Home.spec.tsx
new file mode 100644
index 00000000..ac57122c
--- /dev/null
+++ b/client/src/pages/Home.spec.tsx
@@ -0,0 +1,55 @@
+import {HOME} from 'data-test-ids';
+import {Exchange} from 'redux/modules/settings/types';
+import {respondBasic, history} from 'tests/helpers';
+import {createMainRenderer} from 'tests/influnt';
+import {RoutePath} from './paths';
+import Home from './Home';
+import {createMockedStore} from 'tests/mockStore';
+import {createMemoryHistory} from 'history';
+
+const forgeResult = (data: R) => ({data: {data: data, statusCode: 200}});
+
+const render = createMainRenderer(Home, {
+ extraArgs: () => ({store: createMockedStore(), history: createMemoryHistory()}),
+});
+
+describe('Home page', () => {
+ it('should navigate to exchange on row press even if the api key is not active', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: []}));
+
+ const result = await render({mocks: [mock]})
+ .press(HOME.ROW, {index: 0})
+ .inspect({history: history()});
+
+ expect(result).toEqual({
+ history: RoutePath.BitMex,
+ network: [{getAllApiKeys: [undefined]}],
+ });
+ });
+
+ it('should navigate to exchange if api key is active and pressed on icon', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: [Exchange.BitMeX]}));
+
+ const result = await render({mocks: [mock]})
+ .press(HOME.ICON, {index: 0})
+ .inspect({history: history()});
+
+ expect(result).toEqual({
+ history: RoutePath.BitMex,
+ network: [{getAllApiKeys: [undefined]}],
+ });
+ });
+
+ it('should navigate to settings if api key is not active and pressed on icon', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: []}));
+
+ const result = await render({mocks: [mock]})
+ .press(HOME.ICON, {index: 0})
+ .inspect({history: history()});
+
+ expect(result).toEqual({
+ history: RoutePath.Settings,
+ network: [{getAllApiKeys: [undefined]}],
+ });
+ });
+});
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
new file mode 100644
index 00000000..fd32519a
--- /dev/null
+++ b/client/src/pages/Home.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import {Link} from 'react-router-dom';
+import {CheckIcon, WarningIcon} from '@chakra-ui/icons';
+import {Box, Divider, Heading, Text, Tooltip} from '@chakra-ui/react';
+import {ExchangePresenter} from 'presenters/general-presenters';
+import {Exchange} from 'redux/modules/settings/types';
+import {useDispatch, useSelector} from 'react-redux';
+import {AppState} from 'redux/modules/state';
+import {getAllApiKeys} from 'redux/modules/settings/settingsModule';
+import {HOME} from 'data-test-ids';
+
+interface ExchangeRowProps {
+ exchange: Exchange;
+ isActive: boolean;
+}
+
+function ExchangeRow({exchange, isActive}: ExchangeRowProps) {
+ return (
+
+
+
+
+ {ExchangePresenter[exchange]}
+
+
+
+
+
+
+ {isActive ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
+
+const Home = React.memo(() => {
+ const dispatch = useDispatch();
+ const activeApiKeys = useSelector((state: AppState) => state.settings.activeApiKeys);
+
+ React.useEffect(() => {
+ dispatch(getAllApiKeys());
+ }, [dispatch]);
+
+ return (
+
+
+ Available Exchanges
+
+ {Object.entries(activeApiKeys).map(([exchange, isActive]) => (
+
+ ))}
+
+
+ );
+});
+
+export default Home;
diff --git a/client/src/pages/NotFound.tsx b/client/src/pages/NotFound.tsx
new file mode 100644
index 00000000..84cda840
--- /dev/null
+++ b/client/src/pages/NotFound.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import {Heading, Text} from '@chakra-ui/react';
+import {Link} from 'react-router-dom';
+import {RoutePath} from './paths';
+
+export default function NotFound() {
+ return (
+ <>
+
+ 404 Not Found
+
+
+ Go back to Home
+
+ >
+ );
+}
diff --git a/client/src/pages/Settings.spec.ts b/client/src/pages/Settings.spec.ts
new file mode 100644
index 00000000..1a8ec741
--- /dev/null
+++ b/client/src/pages/Settings.spec.ts
@@ -0,0 +1,107 @@
+import {ADD_API_KEYS_MODAL, GLOBAL, SETTINGS} from 'data-test-ids';
+import {createMemoryHistory} from 'history';
+import {textOfAll} from 'influnt';
+import {Exchange} from 'redux/modules/settings/types';
+import {respondBasic} from 'tests/helpers';
+import {createMainRenderer} from 'tests/influnt';
+import {createMockedStore} from 'tests/mockStore';
+import Settings from './Settings';
+
+const forgeResult = (data: R) => ({data: {data: data, statusCode: 200}});
+
+const render = createMainRenderer(Settings, {
+ extraArgs: () => ({store: createMockedStore(), history: createMemoryHistory()}),
+});
+
+describe('Settings page', () => {
+ it('should display all api keys as inactive', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: []}));
+
+ const result = await render({mocks: [mock]}).inspect({itemStatuses: textOfAll(SETTINGS.API_KEY_ROW_STATUS)});
+
+ expect(result).toEqual({
+ itemStatuses: ['Empty', 'Empty'],
+ network: [{getAllApiKeys: [undefined]}],
+ });
+ });
+
+ it('should display one api keys as active', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: [Exchange.BitMeX]}));
+
+ const result = await render({mocks: [mock]}).inspect({itemStatuses: textOfAll(SETTINGS.API_KEY_ROW_STATUS)});
+
+ expect(result).toEqual({
+ itemStatuses: ['Active', 'Empty'],
+ network: [{getAllApiKeys: [undefined]}],
+ });
+ });
+
+ it('should show modal for clearing the api key if pressed on the active one', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: [Exchange.BitMeX]}));
+
+ const result = await render({mocks: [mock]}).press(SETTINGS.API_KEY_ROW, {index: 0});
+
+ expect(result).toEqual({
+ network: [{getAllApiKeys: [undefined]}],
+ modal: [{showGeneralModal: ['Clear BITMEX API Key', 'This will clear api key entry of BITMEX exchange']}],
+ });
+ });
+
+ it('should show modal for adding the api key if pressed on the empty one', async () => {
+ const mock = respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: [Exchange.BitMeX]}));
+
+ const result = await render({mocks: [mock]}).press(SETTINGS.API_KEY_ROW, {index: 1});
+
+ expect(result).toEqual({
+ network: [{getAllApiKeys: [undefined]}],
+ modal: [{showAddApiKeys: {exchange: Exchange.BitMeXTEST}}],
+ });
+ });
+
+ it('should add api key', async () => {
+ const key = '12312314141414144414';
+ const secret = '12312314141414144414';
+ const [getAllApiKeysResponse, saveApiKeyResponse] = [
+ respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: []})),
+ //@ts-expect-error
+ respondBasic('saveApiKey', [{key, secret}]).with(forgeResult({exchange: Exchange.BitMeX})),
+ ];
+
+ const result = await render({mocks: [getAllApiKeysResponse]})
+ .inspect({itemStatuses: textOfAll(SETTINGS.API_KEY_ROW_STATUS)})
+ .press(SETTINGS.API_KEY_ROW, {index: 0})
+ .inputText(ADD_API_KEYS_MODAL.API_KEY, key)
+ .inputText(ADD_API_KEYS_MODAL.API_SECRET, secret)
+ .press(GLOBAL.MODAL_CONFIRM)
+ .resolve(saveApiKeyResponse)
+ .inspect({itemStatusesAfter: textOfAll(SETTINGS.API_KEY_ROW_STATUS)});
+
+ expect(result).toEqual({
+ itemStatuses: ['Empty', 'Empty'],
+ itemStatusesAfter: ['Active', 'Empty'],
+ modal: [{showAddApiKeys: {exchange: Exchange.BitMeX}}],
+ network: [{getAllApiKeys: [undefined]}, {saveApiKey: [{key, secret}]}],
+ });
+ });
+
+ it('should remove api key', async () => {
+ const [getAllApiKeysResponse, deleteApiKeyResponse] = [
+ respondBasic('getAllApiKeys', [undefined]).with(forgeResult({exchanges: [Exchange.BitMeX]})),
+ respondBasic('deleteApiKey', [Exchange.BitMeX]).with(forgeResult(Exchange.BitMeX)),
+ ];
+
+ const result = await render({mocks: [getAllApiKeysResponse]})
+ .inspect({itemStatuses: textOfAll(SETTINGS.API_KEY_ROW_STATUS)})
+ .press(SETTINGS.API_KEY_ROW, {index: 0})
+ .press(GLOBAL.MODAL_CONFIRM)
+ .resolve(deleteApiKeyResponse)
+ .inspect({itemStatusesAfter: textOfAll(SETTINGS.API_KEY_ROW_STATUS)});
+
+ expect(result).toEqual({
+ itemStatuses: ['Active', 'Empty'],
+ itemStatusesAfter: ['Empty', 'Empty'],
+ modal: [{showGeneralModal: ['Clear BITMEX API Key', 'This will clear api key entry of BITMEX exchange']}],
+ network: [{getAllApiKeys: [undefined]}, {deleteApiKey: [Exchange.BitMeX]}],
+ });
+ });
+});
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
new file mode 100644
index 00000000..4f4ad457
--- /dev/null
+++ b/client/src/pages/Settings.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import {useDispatch, useSelector} from 'react-redux';
+import {Badge, Box, Divider, Heading, Text} from '@chakra-ui/react';
+import {Button} from 'components';
+import {AppState} from 'redux/modules/state';
+import {Exchange} from 'redux/modules/settings/types';
+import {useModal} from 'general/hooks';
+import {deleteAllApiKeys, deleteApiKey, getAllApiKeys} from 'redux/modules/settings/settingsModule';
+import {ExchangePresenter} from 'presenters/general-presenters';
+import {SETTINGS} from 'data-test-ids';
+
+interface ItemProps {
+ title: string;
+ isActive: boolean;
+ exchange: Exchange;
+ onClick: (isActive: boolean, exchange: Exchange) => void;
+}
+
+const ApiKeySettingRow = React.memo(({title, isActive, exchange, onClick}: ItemProps) => {
+ const color = isActive ? '#4caf50' : 'grey';
+ return (
+ onClick(isActive, exchange)}
+ >
+
+
+ {title}
+
+ Add api keys for authenticated requests
+
+
+ {isActive ? Active : Empty }
+
+
+ );
+});
+
+export default function Settings() {
+ const dispatch = useDispatch();
+ const {modals} = useModal();
+
+ const activeApiKeys = useSelector((state: AppState) => state.settings.activeApiKeys);
+
+ React.useEffect(() => {
+ dispatch(getAllApiKeys());
+ }, [dispatch]);
+
+ const confirmDeleteAllApiKeys = React.useCallback(() => {
+ modals.showGeneralModal({
+ title: 'Clear all API Keys',
+ subtitle: 'This will clear all api keys and remove the folder saving them',
+ onConfirm: () => dispatch(deleteAllApiKeys()),
+ });
+ }, [dispatch, modals]);
+
+ const configureApiKey = React.useCallback(
+ (isActive: boolean, exchange: Exchange) => {
+ isActive
+ ? modals.showGeneralModal({
+ title: `Clear ${ExchangePresenter[exchange]} API Key`,
+ subtitle: `This will clear api key entry of ${ExchangePresenter[exchange]} exchange`,
+ onConfirm: () => dispatch(deleteApiKey(exchange)),
+ })
+ : modals.showAddApiKeys({exchange});
+ },
+ [modals, dispatch],
+ );
+
+ return (
+
+
+ Settings
+
+ {Object.entries(activeApiKeys).map(([exchange, isActive]) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/src/pages/paths.ts b/client/src/pages/paths.ts
new file mode 100644
index 00000000..8ce504da
--- /dev/null
+++ b/client/src/pages/paths.ts
@@ -0,0 +1,8 @@
+import {Exchange} from 'redux/modules/settings/types';
+
+export const RoutePath = {
+ Home: '/',
+ BitMex: '/' + Exchange.BitMeX,
+ BitmexTest: '/' + Exchange.BitMeXTEST,
+ Settings: '/settings',
+};
diff --git a/client/src/presenters/general-presenters.ts b/client/src/presenters/general-presenters.ts
new file mode 100644
index 00000000..a49e6a02
--- /dev/null
+++ b/client/src/presenters/general-presenters.ts
@@ -0,0 +1,6 @@
+import {Exchange} from 'redux/modules/settings/types';
+
+export const ExchangePresenter: Record = {
+ [Exchange.BitMeX]: 'BITMEX',
+ [Exchange.BitMeXTEST]: 'BITMEX Testnet',
+};
diff --git a/client/src/redux/api/api.ts b/client/src/redux/api/api.ts
index d37f9e10..9c6e01e1 100644
--- a/client/src/redux/api/api.ts
+++ b/client/src/redux/api/api.ts
@@ -1,49 +1,54 @@
-//@ts-nocheck
-import {Api as BitmexAPI} from './bitmex';
-import {createProfitTarget, MarketOrderProps, ProfitTarget, ProfitTargetProps, RegularOrder, StopLoss} from 'utils';
-import {EXEC_INST, Order, ORD_TYPE} from './bitmex/types';
-
-export type LimitOrder = Pick;
-export type OrderAmend = Pick;
-export type OrderCancel = {orderID: string[] | string};
-export type OrderBulk = RegularOrder | StopLoss | ProfitTarget;
-
-export type APIType = ClassMethods;
-
-type Exchange = 'bitmex';
-
-export type AvailableMethods = API['availableMethods']['bitmex'];
-
-type GetQuery = (method: K) => AvailableMethods[K];
-
-export class API implements APIType {
- constructor(private activeExchange: Exchange = 'bitmex', private bitmex = new BitmexAPI()) {}
-
- private availableMethods = {
- bitmex: {
- marketOrder: (props: MarketOrderProps) => this.bitmex.order.orderNew({...props, ordType: ORD_TYPE.Market}),
- limitOrder: (props: LimitOrder) =>
- this.bitmex.order.orderNew({...props, execInst: EXEC_INST.ParticipateDoNotInitiate, ordType: ORD_TYPE.Limit}),
- profitTargetOrder: (props: ProfitTargetProps) => this.bitmex.order.orderNew(createProfitTarget(props)),
- orderAmend: (props: OrderAmend) => this.bitmex.order.orderAmend(props),
- orderCancel: (props: OrderCancel) => this.bitmex.order.orderCancel(props),
- orderCancelAll: () => this.bitmex.order.orderCancelAll({}),
- orderBulk: (orders: OrderBulk[]) => this.bitmex.order.orderNewBulk({orders}),
- getBalance: () => this.bitmex.user.userGetMargin(),
- getOpenOrders: () => this.bitmex.order.orderGetOrders({filter: '{"open": true}', reverse: true}),
- },
- };
-
- getQuery: GetQuery = (method) => {
- const query = this.availableMethods[this.activeExchange]?.[method];
- if (!query) throw new Error(`${method} for ${this.activeExchange} hasn't been implemented yet`);
- return query;
+import axios, {Method} from 'axios';
+import {Exchange} from 'redux/modules/settings/types';
+import {BitmexBlock} from './bitmex/bitmex-block';
+
+export type ExchangeAPIFacadeType = ClassMethods;
+export type AvailableMethods = BitmexBlock;
+type GetQuery = (exchange: Exchange) => (method: K) => BitmexBlock[K];
+
+export class ExchangeAPIFacade implements ExchangeAPIFacadeType {
+ private BitmexBlock: BitmexBlock = new BitmexBlock();
+
+ getQuery: GetQuery = (exchange) => {
+ return (method) => {
+ switch (exchange) {
+ case Exchange.BitMeX:
+ return this.BitmexBlock[method];
+ case Exchange.BitMeXTEST:
+ return this.BitmexBlock[method];
+ default:
+ return this.BitmexBlock[method];
+ }
+ };
};
}
-export type MethodNames = KeysByType;
-export type MethodProps = Parameters[MethodNames]>[number];
+export type BasicAPIType = ClassMethods;
+
+export class BasicAPI implements BasicAPIType {
+ private async request(url: string, method: Method, data?: unknown) {
+ return axios({url, method, data: data, params: data}) as Promise<{data: {data: R}}>;
+ }
+
+ async saveApiKey(params: {exchange: Exchange; key: string; secret: string}) {
+ return this.request<{exchange: Exchange}>('/settings/apiKey', 'POST', params);
+ }
+
+ async getApiKey(params: {exchange: Exchange; key: string; secret: string}) {
+ return this.request<{exchange: Exchange; key: string; secret: string}>('/settings/apiKey', 'GET', params);
+ }
+
+ async getAllApiKeys() {
+ return this.request<{exchanges: Exchange[]}>('/settings/apiKeys', 'GET');
+ }
+
+ async deleteApiKey(exchange: Exchange) {
+ return this.request('/settings/apiKey', 'DELETE', exchange);
+ }
+
+ async deleteAllApiKeys() {
+ return this.request('/settings/apiKeys', 'DELETE');
+ }
+}
-export type MockedMethods = {
- [key in keyof API['availableMethods']['bitmex']]: {props: any; result: any};
-}[];
+export const basicApi = new BasicAPI();
diff --git a/client/src/redux/api/bitmex/bitmex-block.ts b/client/src/redux/api/bitmex/bitmex-block.ts
new file mode 100644
index 00000000..11178fd9
--- /dev/null
+++ b/client/src/redux/api/bitmex/bitmex-block.ts
@@ -0,0 +1,27 @@
+import {createProfitTarget, MarketOrderProps, ProfitTarget, ProfitTargetProps, RegularOrder, StopLoss} from 'utils';
+import {Api as BitmexAPI} from '.';
+import {EXEC_INST, Order, ORD_TYPE} from './types';
+
+export type LimitOrder = Pick;
+export type OrderAmend = Pick;
+export type OrderCancel = {orderID: string[] | string};
+export type OrderBulk = RegularOrder | StopLoss | ProfitTarget;
+
+export class BitmexBlock {
+ private bitmex: BitmexAPI;
+
+ constructor() {
+ this.bitmex = new BitmexAPI();
+ }
+
+ marketOrder = (props: MarketOrderProps) => this.bitmex.order.orderNew({...props, ordType: ORD_TYPE.Market});
+ limitOrder = (props: LimitOrder) =>
+ this.bitmex.order.orderNew({...props, execInst: EXEC_INST.ParticipateDoNotInitiate, ordType: ORD_TYPE.Limit});
+ profitTargetOrder = (props: ProfitTargetProps) => this.bitmex.order.orderNew(createProfitTarget(props));
+ orderAmend = (props: OrderAmend) => this.bitmex.order.orderAmend(props);
+ orderCancel = (props: OrderCancel) => this.bitmex.order.orderCancel(props);
+ orderCancelAll = () => this.bitmex.order.orderCancelAll({});
+ orderBulk = (props: {orders: OrderBulk[]}) => this.bitmex.order.orderNewBulk({orders: props.orders});
+ getBalance = () => this.bitmex.user.userGetMargin();
+ getOpenOrders = () => this.bitmex.order.orderGetOrders({filter: '{"open": true}', reverse: true});
+}
diff --git a/client/src/redux/api/bitmex/index.ts b/client/src/redux/api/bitmex/index.ts
index 69b93ca2..d9d1ab5a 100644
--- a/client/src/redux/api/bitmex/index.ts
+++ b/client/src/redux/api/bitmex/index.ts
@@ -1,6 +1,6 @@
import axios from 'axios';
import _ from 'lodash/fp';
-import {OrderBulk} from '../api';
+import {OrderBulk} from './bitmex-block';
import {
AccessToken,
Affiliate,
diff --git a/client/src/redux/helpers/actionHelpers.ts b/client/src/redux/helpers/actionHelpers.ts
index 875d2238..f037da81 100644
--- a/client/src/redux/helpers/actionHelpers.ts
+++ b/client/src/redux/helpers/actionHelpers.ts
@@ -6,13 +6,14 @@ import {
ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit';
import {HttpResponse} from 'redux/api/bitmex';
-import {API} from 'redux/api/api';
+import {ExchangeAPIFacade, AvailableMethods, BasicAPIType, basicApi} from 'redux/api/api';
import {AppState} from 'redux/modules/state';
import {ACTIONS_cross} from 'redux/modules/cross/types';
import {ACTIONS_orders} from 'redux/modules/orders/types';
import {ACTIONS_preview} from 'redux/modules/preview/types';
import {ACTIONS_trailing} from 'redux/modules/trailing/types';
+import {ACTIONS_settings} from 'redux/modules/settings/types';
export const createAction = (actionName: string) =>
createAction_toolkit(actionName, (payload: P) => ({payload}));
@@ -22,19 +23,25 @@ export type CreateAction = {
error: ActionCreatorWithPreparedPayload<[string], string, string, never, never>;
};
-const reducerActions = [...ACTIONS_preview, ...ACTIONS_trailing, ...ACTIONS_cross, ...ACTIONS_orders];
+const reducerActions = [
+ ...ACTIONS_preview,
+ ...ACTIONS_trailing,
+ ...ACTIONS_cross,
+ ...ACTIONS_orders,
+ ...ACTIONS_settings,
+];
type ThunkActionNames = typeof reducerActions[number];
export interface ThunkApiConfig {
rejected: string;
- extra: API;
+ extra: ExchangeAPIFacade;
dispatch: Dispatch;
rejectValue: string;
state: AppState;
}
-type BitmexMethods = API['availableMethods']['bitmex'];
+type BitmexMethods = AvailableMethods;
type Raw
>> = P extends Promise> ? U : never;
@@ -48,7 +55,7 @@ interface AAA {
payloadToReturn?: keyof P;
}
-export function createThunkV2[number], R>({
+export function createApiThunk[number], R>({
actionName,
apiMethod,
parseResponse,
@@ -60,16 +67,53 @@ export function createThunkV2 {
//@ts-expect-error
- return createAsyncThunk(actionName, async (payload: P, {extra: API, rejectWithValue, getState}) => {
+ return createAsyncThunk(
+ actionName,
+ async (payload: P, {extra: API, rejectWithValue, getState}) => {
+ try {
+ const {activeExchange} = getState().settings;
+ const adaptedPayload = adaptPayload?.(payload, getState) ?? payload;
+ // TODO: add a proper type, there may not fix a fix for this tho
+ //@ts-expect-error
+ const {data} = await API.getQuery(activeExchange)(apiMethod)(adaptedPayload);
+ //@ts-expect-error
+ const responseData = {data: parseResponse(JSON.parse(data.data)), statusCode: data.statusCode};
+ const extraData = payloadToReturn ? {[payloadToReturn]: payload[payloadToReturn]} : {};
+ return {...responseData, ...extraData};
+ } catch (err) {
+ console.log(err, 'errr');
+ return rejectWithValue(formatErrorMessage(err));
+ }
+ },
+ {
+ condition: (_, {getState}) => {
+ const {activeExchange, activeApiKeys} = getState().settings;
+ return Boolean(activeExchange && (activeApiKeys?.[activeExchange] ?? false));
+ },
+ dispatchConditionRejection: true,
+ },
+ );
+}
+
+interface BasicThunkConfig {
+ actionName: ThunkActionNames;
+ method: K;
+}
+
+export function createBasicThunk[number]>({
+ actionName,
+ method,
+}: BasicThunkConfig): AsyncThunk<
+ RawType>['data']['data'],
+ Parameters[number] extends never ? void : P,
+ ThunkApiConfig
+> {
+ //@ts-ignore
+ return createAsyncThunk(actionName, async (payload: P, {rejectWithValue}) => {
try {
- const adaptedPayload = adaptPayload?.(payload, getState) ?? payload;
- // TODO: add a proper type, there may not fix a fix for this tho
- //@ts-expect-error
- const {data} = await API.getQuery(apiMethod)(adaptedPayload);
- //@ts-expect-error
- const responseData = {data: parseResponse(JSON.parse(data.data)), statusCode: data.statusCode};
- const extraData = payloadToReturn ? {[payloadToReturn]: payload[payloadToReturn]} : {};
- return {...responseData, ...extraData};
+ //@ts-ignore
+ const response = await basicApi[method](payload);
+ return response.data.data;
} catch (err) {
return rejectWithValue(formatErrorMessage(err));
}
diff --git a/client/src/redux/helpers/hookHelpers.ts b/client/src/redux/helpers/hookHelpers.ts
index 795b71cd..6522d8ce 100644
--- a/client/src/redux/helpers/hookHelpers.ts
+++ b/client/src/redux/helpers/hookHelpers.ts
@@ -14,40 +14,24 @@ import {
groupedOrdersSelector,
} from 'redux/selectors';
import {AppState} from 'redux/modules/state';
+import {Exchange} from 'redux/modules/settings/types';
-type States = UnionToIntersection>;
+type Selectors = ReturnType;
-interface Selectors extends States {
- wsCurrentPrice: ReturnType;
- wsTrailingPrice: ReturnType;
- wsCrossPrice: ReturnType;
- wsBidAskPrices: ReturnType;
- allPrices: ReturnType;
- averagePrice: ReturnType;
- riskBTC: ReturnType;
- riskPerc: ReturnType;
- status: ReturnType;
- hasCrossedOnce: ReturnType;
- hasCrossedSecondTime: ReturnType;
- groupedOrders: ReturnType;
- wsLoading: boolean;
- wsMessage: any;
-}
-
-const buildSelectors = (state: AppState): Selectors => {
- const {trailing, preview, websocket, cross, orders} = state;
+const buildSelectors = (state: AppState, exchange: Exchange) => {
+ const {trailing, preview, websocket, cross, orders, settings} = state;
return {
- wsCurrentPrice: websocketCurrentPrice(state),
- wsTrailingPrice: websocketTrailingPriceSelector(state),
- wsCrossPrice: websocketCrossPriceSelector(state),
- wsBidAskPrices: websocketBidAskPrices(state),
- allPrices: allWebsocketBidAskPrices(state),
+ wsCurrentPrice: websocketCurrentPrice(state, exchange),
+ wsTrailingPrice: websocketTrailingPriceSelector(state, exchange),
+ wsCrossPrice: websocketCrossPriceSelector(state, exchange),
+ wsBidAskPrices: websocketBidAskPrices(state, exchange),
+ allPrices: allWebsocketBidAskPrices(state, exchange),
averagePrice: ordersAverageEntrySelector(state),
riskBTC: ordersRiskSelector(state),
riskPerc: ordersRiskPercSelector(state),
- status: trailingOrderStatusSelector(state),
- hasCrossedOnce: hasCrossedOnceSelector(state),
- hasCrossedSecondTime: hasCrossedSecondTimeSelector(state),
+ status: trailingOrderStatusSelector(state, exchange),
+ hasCrossedOnce: hasCrossedOnceSelector(state, exchange),
+ hasCrossedSecondTime: hasCrossedSecondTimeSelector(state, exchange),
groupedOrders: groupedOrdersSelector(state),
trailOrderId: trailing.trailOrderId,
@@ -69,24 +53,30 @@ const buildSelectors = (state: AppState): Selectors => {
showPreview: preview.showPreview,
previewLoading: preview.previewLoading,
- __keys: websocket.__keys,
- connected: websocket.connected,
- instrument: websocket.instrument,
- order: websocket.order,
- wsLoading: websocket.wsLoading,
- wsMessage: websocket.message,
+ __keys: websocket[exchange].__keys,
+ connected: websocket[exchange].connected,
+ instrument: websocket[exchange].instrument,
+ order: websocket[exchange].order,
+ wsLoading: websocket[exchange].wsLoading,
+ wsMessage: websocket[exchange].message,
openOrders: orders.openOrders,
ordersError: orders.ordersError,
ordersLoading: orders.ordersLoading,
profitOrders: orders.profitOrders,
profitOrdersInAction: orders.profitOrdersInAction,
+
+ activeApiKeys: settings.activeApiKeys,
+ settingsError: settings.settingsError,
+ settingsLoading: settings.settingsLoading,
+ activeExchange: settings.activeExchange,
+ getAllApiKeysLoading: settings.getAllApiKeysLoading,
};
};
-export function useReduxSelector(...keys: K[]): Pick {
+export function useReduxSelector(exchange: Exchange, ...keys: K[]): Pick {
const selector = useSelector((state: AppState) => {
- const builtSelectors = buildSelectors(state);
+ const builtSelectors = buildSelectors(state, exchange);
return keys.reduce(
(availableSelectors: Pick, selectorKey: K) => (
(availableSelectors[selectorKey] = builtSelectors[selectorKey]), availableSelectors
diff --git a/client/src/redux/middlewares/toast-notification.ts b/client/src/redux/middlewares/toast-notification.ts
index 627e5841..ce08edb9 100644
--- a/client/src/redux/middlewares/toast-notification.ts
+++ b/client/src/redux/middlewares/toast-notification.ts
@@ -8,11 +8,12 @@ import {postMarketCrossOrder as crossPostMarketOrder} from 'redux/modules/cross/
import {addProfitTarget, cancelOrder} from 'redux/modules/orders/ordersModule';
import {SIDE} from 'redux/api/bitmex/types';
-const middleware: Middleware> = (store) => (next) => (action: Action) => {
- registeredToasts[action.type]?.(action);
+const middleware: Middleware> = (store) => (next) => (action: AnyAction) => {
+ // if condition is true, request is unauthenticated because of async thunk condition
+ const payload = action?.meta?.condition ? 'Unauthenticated' : action.payload;
+ registeredToasts[action.type]?.({...action, payload});
next(action);
};
-
type RequestFunction = (action: Action) => void;
type ThunkToasts = {[key: string]: RequestFunction};
diff --git a/client/src/redux/modules/cross/crossModule.ts b/client/src/redux/modules/cross/crossModule.ts
index 00f634e7..45ce670e 100644
--- a/client/src/redux/modules/cross/crossModule.ts
+++ b/client/src/redux/modules/cross/crossModule.ts
@@ -1,5 +1,5 @@
import {createReducer} from '@reduxjs/toolkit';
-import {createAction, createThunkV2} from 'redux/helpers/actionHelpers';
+import {createAction, createApiThunk} from 'redux/helpers/actionHelpers';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
import {CrossState, CREATE_CROSS_ORDER, CLEAR_CROSS_ORDER, CROSS_POST_MARKET_ORDER, ORDER_CROSSED_ONCE} from './types';
@@ -16,7 +16,7 @@ export const createCrossOrder = createAction(CREATE_CRO
export const clearCrossOrder = createAction(CLEAR_CROSS_ORDER);
export const orderCrossedOnce = createAction(ORDER_CROSSED_ONCE);
-export const postMarketCrossOrder = createThunkV2({
+export const postMarketCrossOrder = createApiThunk({
actionName: CROSS_POST_MARKET_ORDER,
apiMethod: 'marketOrder',
adaptPayload: (_, getState) => {
@@ -30,13 +30,13 @@ export const crossReducer = createReducer(defaultState, (builder) =>
builder
.addCase(postMarketCrossOrder.fulfilled, () => defaultState)
.addCase(postMarketCrossOrder.rejected, () => defaultState)
+ .addCase(clearCrossOrder, () => defaultState)
.addCase(createCrossOrder, (state, {payload}) => {
state.crossOrderSymbol = payload.symbol;
state.crossOrderSide = payload.side;
state.crossOrderPrice = payload.price;
state.crossOrderQuantity = payload.orderQty;
})
- .addCase(clearCrossOrder, () => defaultState)
.addCase(orderCrossedOnce, (state) => {
state.hasPriceCrossedOnce = true;
}),
diff --git a/client/src/redux/modules/orders/ordersModule.ts b/client/src/redux/modules/orders/ordersModule.ts
index a2dc1347..d0e35564 100644
--- a/client/src/redux/modules/orders/ordersModule.ts
+++ b/client/src/redux/modules/orders/ordersModule.ts
@@ -1,41 +1,41 @@
import _ from 'lodash';
import {createReducer} from '@reduxjs/toolkit';
import {Order} from 'redux/api/bitmex/types';
-import {createThunkV2} from 'redux/helpers/actionHelpers';
+import {createApiThunk} from 'redux/helpers/actionHelpers';
import * as types from './types';
-export const cancelOrder = createThunkV2({
+export const cancelOrder = createApiThunk({
actionName: types.CANCEL_ORDER,
apiMethod: 'orderCancel',
parseResponse: (data) => ({orderID: data.map(({orderID}) => orderID)}),
});
-export const cancelAllOrders = createThunkV2({
+export const cancelAllOrders = createApiThunk({
actionName: types.CANCEL_ALL_ORDERS,
apiMethod: 'orderCancelAll',
parseResponse: (data) => data,
});
-export const cancelAllProfitOrders = createThunkV2({
+export const cancelAllProfitOrders = createApiThunk({
actionName: types.CANCEL_ALL_PROFIT_ORDERS,
apiMethod: 'orderCancel',
parseResponse: (data) => ({orderID: data.map(({orderID}) => orderID)}),
});
-export const getOpenOrders = createThunkV2({
+export const getOpenOrders = createApiThunk({
actionName: types.GET_OPEN_ORDERS,
apiMethod: 'getOpenOrders',
parseResponse: (data) => data,
});
-export const addProfitTarget = createThunkV2({
+export const addProfitTarget = createApiThunk({
actionName: types.ADD_PROFIT_ORDER,
apiMethod: 'profitTargetOrder',
parseResponse: (data) => data,
payloadToReturn: 'orderID',
});
-export const cancelProfitOrder = createThunkV2({
+export const cancelProfitOrder = createApiThunk({
actionName: types.REMOVE_PROFIT_ORDER,
apiMethod: 'orderCancel',
parseResponse: (data) => ({orderID: data.map(({orderID}) => orderID)}),
diff --git a/client/src/redux/modules/preview/previewModule.ts b/client/src/redux/modules/preview/previewModule.ts
index e255f8cf..576ad79b 100644
--- a/client/src/redux/modules/preview/previewModule.ts
+++ b/client/src/redux/modules/preview/previewModule.ts
@@ -1,6 +1,6 @@
import {createReducer} from '@reduxjs/toolkit';
import {createScaledOrders, DistributionProps, DISTRIBUTION} from 'utils';
-import {createAction, createThunkV2} from 'redux/helpers/actionHelpers';
+import {createAction, createApiThunk} from 'redux/helpers/actionHelpers';
import {
SHOW_PREVIEW,
TOGGLE_PREVIEW,
@@ -11,19 +11,19 @@ import {
PREVIEW_POST_MARKET_ORDER,
} from './types';
-export const postMarketOrder = createThunkV2({
+export const postMarketOrder = createApiThunk({
actionName: PREVIEW_POST_MARKET_ORDER,
apiMethod: 'marketOrder',
parseResponse: (data) => data,
});
-export const postOrderBulk = createThunkV2({
+export const postOrderBulk = createApiThunk({
actionName: PREVIEW_POST_ORDER,
apiMethod: 'orderBulk',
parseResponse: (data) => data,
});
-export const getBalance = createThunkV2({
+export const getBalance = createApiThunk({
actionName: GET_BALANCE,
apiMethod: 'getBalance',
parseResponse: (data) => ({walletBalance: data.walletBalance}),
diff --git a/client/src/redux/modules/settings/settingsModule.ts b/client/src/redux/modules/settings/settingsModule.ts
new file mode 100644
index 00000000..6b8a1730
--- /dev/null
+++ b/client/src/redux/modules/settings/settingsModule.ts
@@ -0,0 +1,64 @@
+import {createReducer} from '@reduxjs/toolkit';
+import {createAction, createBasicThunk} from 'redux/helpers/actionHelpers';
+import {
+ ACTIVATE_EXCHANGE,
+ Exchange,
+ DELETE_ALL_API_KEYS,
+ DELETE_API_KEY,
+ GET_ALL_API_KEYS,
+ GET_API_KEY,
+ SAVE_API_KEY,
+ SettingsState,
+} from './types';
+
+export const activateExchange = createAction(ACTIVATE_EXCHANGE);
+
+export const saveApiKey = createBasicThunk({actionName: SAVE_API_KEY, method: 'saveApiKey'});
+
+export const getApiKey = createBasicThunk({actionName: GET_API_KEY, method: 'getApiKey'});
+
+export const getAllApiKeys = createBasicThunk({actionName: GET_ALL_API_KEYS, method: 'getAllApiKeys'});
+
+export const deleteApiKey = createBasicThunk({actionName: DELETE_API_KEY, method: 'deleteApiKey'});
+
+export const deleteAllApiKeys = createBasicThunk({actionName: DELETE_ALL_API_KEYS, method: 'deleteAllApiKeys'});
+
+export const defaultState: SettingsState = {
+ activeApiKeys: {bitmex: false, bitmexTEST: false},
+ settingsLoading: false,
+ settingsError: '',
+ activeExchange: undefined,
+ getAllApiKeysLoading: false,
+};
+
+export const settingsReducer = createReducer(defaultState, (builder) =>
+ builder
+ .addCase(activateExchange, (state, {payload}) => {
+ state.activeExchange = payload;
+ })
+ .addCase(saveApiKey.pending, (state) => {
+ state.settingsError = '';
+ state.settingsLoading = true;
+ })
+ .addCase(saveApiKey.fulfilled, (state, {payload}) => {
+ state.settingsError = '';
+ state.settingsLoading = false;
+ state.activeApiKeys[payload.exchange] = true;
+ })
+ .addCase(getApiKey.fulfilled, (state) => {
+ return state;
+ })
+ .addCase(getAllApiKeys.pending, (state) => {
+ state.getAllApiKeysLoading = true;
+ })
+ .addCase(getAllApiKeys.fulfilled, (state, {payload}) => {
+ state.getAllApiKeysLoading = false;
+ payload.exchanges.forEach((exchange) => void (state.activeApiKeys[exchange] = true));
+ })
+ .addCase(deleteAllApiKeys.fulfilled, () => defaultState)
+ .addCase(deleteApiKey.fulfilled, (state, {payload}) => {
+ state.settingsError = '';
+ state.settingsLoading = false;
+ state.activeApiKeys[payload] = false;
+ }),
+);
diff --git a/client/src/redux/modules/settings/types.ts b/client/src/redux/modules/settings/types.ts
new file mode 100644
index 00000000..81ae2081
--- /dev/null
+++ b/client/src/redux/modules/settings/types.ts
@@ -0,0 +1,28 @@
+export const SAVE_API_KEY = 'settings/SAVE_API_KEY';
+export const GET_API_KEY = 'settings/GET_API_KEY';
+export const GET_ALL_API_KEYS = 'settings/GET_ALL_API_KEYS';
+export const DELETE_API_KEY = 'settings/DELETE_API_KEY';
+export const DELETE_ALL_API_KEYS = 'settings/DELETE_ALL_API_KEYS';
+export const ACTIVATE_EXCHANGE = 'settings/ACTIVATE_EXCHANGE';
+
+export enum Exchange {
+ BitMeX = 'bitmex',
+ BitMeXTEST = 'bitmexTEST',
+}
+
+export interface SettingsState {
+ activeApiKeys: Record;
+ activeExchange: Exchange | undefined;
+ settingsLoading: boolean;
+ settingsError: string;
+ getAllApiKeysLoading: boolean; // TODO remove
+}
+
+export const ACTIONS_settings = [
+ SAVE_API_KEY,
+ GET_API_KEY,
+ GET_ALL_API_KEYS,
+ DELETE_API_KEY,
+ DELETE_ALL_API_KEYS,
+ ACTIVATE_EXCHANGE,
+] as const;
diff --git a/client/src/redux/modules/state.ts b/client/src/redux/modules/state.ts
index 376926ff..44d6cf43 100644
--- a/client/src/redux/modules/state.ts
+++ b/client/src/redux/modules/state.ts
@@ -1,14 +1,15 @@
import {ThunkAction} from 'redux-thunk';
import {Action} from 'redux';
-import {APIType} from 'redux/api/api';
+import {ExchangeAPIFacadeType} from 'redux/api/api';
import {PreviewState} from 'redux/modules/preview/types';
import {TrailingState} from 'redux/modules/trailing/types';
import {WebsocketState} from 'redux/modules/websocket/types';
import {CrossState} from 'redux/modules/cross/types';
import {OrdersState} from 'redux/modules/orders/types';
+import {SettingsState} from './settings/types';
-export type Thunk = ThunkAction>;
+export type Thunk = ThunkAction>;
export interface AppState {
preview: PreviewState;
@@ -16,4 +17,5 @@ export interface AppState {
trailing: TrailingState;
cross: CrossState;
orders: OrdersState;
+ settings: SettingsState;
}
diff --git a/client/src/redux/modules/trailing/trailingModule.ts b/client/src/redux/modules/trailing/trailingModule.ts
index 610bc545..4391cd7d 100644
--- a/client/src/redux/modules/trailing/trailingModule.ts
+++ b/client/src/redux/modules/trailing/trailingModule.ts
@@ -1,5 +1,5 @@
import {createReducer} from '@reduxjs/toolkit';
-import {createAction, createThunkV2} from 'redux/helpers/actionHelpers';
+import {createAction, createApiThunk} from 'redux/helpers/actionHelpers';
import {SIDE, SYMBOL} from 'redux/api/bitmex/types';
import * as types from './types';
@@ -16,20 +16,20 @@ export const __clearTrailingOrder = createAction(types.__CLEAR_TRAILING_ORDER);
export const changeTrailingOrderSymbol = createAction(types.CHANGE_TRAILING_ORDER_SYMBOL);
-export const postTrailingOrder = createThunkV2({
+export const postTrailingOrder = createApiThunk({
actionName: types.POST_TRAILING_ORDER,
apiMethod: 'limitOrder',
parseResponse: (data) => ({orderID: data.orderID, price: data.price, text: data.text}),
payloadToReturn: 'side',
});
-export const ammendTrailingOrder = createThunkV2({
+export const ammendTrailingOrder = createApiThunk({
actionName: types.PUT_TRAILING_ORDER,
apiMethod: 'orderAmend',
parseResponse: (data) => ({price: data.price}),
});
-export const cancelTrailingOrder = createThunkV2({
+export const cancelTrailingOrder = createApiThunk({
actionName: types.DELETE_TRAILING_ORDER,
apiMethod: 'orderCancel',
adaptPayload: (_, getState) => ({orderID: getState().trailing.trailOrderId}),
diff --git a/client/src/redux/modules/websocket/constants.ts b/client/src/redux/modules/websocket/constants.ts
index 65af2b53..f7a0d70f 100644
--- a/client/src/redux/modules/websocket/constants.ts
+++ b/client/src/redux/modules/websocket/constants.ts
@@ -1,13 +1,12 @@
import {SYMBOL} from 'redux/api/bitmex/types';
+import {Exchange} from '../settings/types';
-enum Exchange {
- BITMEX = 'bitmex',
-}
+const baseUrls: {[key in Exchange]: string} = {
+ [Exchange.BitMeX]: `wss://www.bitmex.com/realtime?subscribe=`,
+ [Exchange.BitMeXTEST]: `wss://testnet.bitmex.com/realtime?subscribe=`,
+};
-export function websocketBaseUrl(exchange: Exchange = Exchange.BITMEX) {
- const baseUrls: {[key in Exchange]: string} = {
- bitmex: `wss://${process.env.REACT_APP___TESTNET === 'true' ? 'testnet' : 'www'}.bitmex.com/realtime?subscribe=`,
- };
+export function websocketBaseUrl(exchange: Exchange) {
return baseUrls[exchange];
}
diff --git a/client/src/redux/modules/websocket/types.ts b/client/src/redux/modules/websocket/types.ts
index ad1f11e0..225aaae6 100644
--- a/client/src/redux/modules/websocket/types.ts
+++ b/client/src/redux/modules/websocket/types.ts
@@ -10,6 +10,7 @@ import {
WEBSOCKET_ERROR,
} from '@giantmachines/redux-websocket';
import {Instrument, Order} from '../../api/bitmex/types';
+import {Exchange} from '../settings/types';
type CreateNoPayloadAction = Action;
@@ -32,13 +33,15 @@ export type WebsocketActions = CreateAction;
export type ReduxWebsocketMessage = CreateAction;
-export interface WebsocketState extends Tables {
- __keys: Keys;
- connected: boolean;
- wsLoading: boolean;
- message?: string;
- error: string;
-}
+export type WebsocketState = {
+ [exchange in Exchange]: Tables & {
+ __keys: Keys;
+ connected: boolean;
+ wsLoading: boolean;
+ message?: string;
+ error: string;
+ };
+};
export enum RESPONSE_ACTIONS {
PARTIAL = 'partial',
diff --git a/client/src/redux/modules/websocket/websocketModule.ts b/client/src/redux/modules/websocket/websocketModule.ts
index 01e6e536..35dd9cdf 100644
--- a/client/src/redux/modules/websocket/websocketModule.ts
+++ b/client/src/redux/modules/websocket/websocketModule.ts
@@ -1,27 +1,25 @@
+import {WebsocketResponse, WebsocketState, RESPONSE_ACTIONS, FETCH_ORDERS, Tables, TableData} from './types';
import {
- WebsocketResponse,
- WebsocketState,
- RESPONSE_ACTIONS,
- FETCH_ORDERS,
- REDUX_WEBSOCKET_BROKEN,
- REDUX_WEBSOCKET_CLOSED,
- REDUX_WEBSOCKET_CONNECT,
- REDUX_WEBSOCKET_MESSAGE,
- REDUX_WEBSOCKET_OPEN,
- REDUX_WEBSOCKET_SEND,
- REDUX_WEBSOCKET_ERROR,
- Tables,
- TableData,
-} from './types';
-import {connect, disconnect, send} from '@giantmachines/redux-websocket';
+ connect,
+ disconnect,
+ send,
+ WEBSOCKET_BROKEN,
+ WEBSOCKET_CLOSED,
+ WEBSOCKET_CONNECT,
+ WEBSOCKET_MESSAGE,
+ WEBSOCKET_OPEN,
+ WEBSOCKET_SEND,
+ WEBSOCKET_ERROR,
+} from '@giantmachines/redux-websocket';
import {Thunk} from '../state';
import {Reducer} from 'redux';
import {authKeyExpires} from 'utils/auth';
import {SUBSCRIPTION_TOPICS, SYMBOL} from 'redux/api/bitmex/types';
import {websocketBaseUrl, instrumentTopics} from './constants';
import {Instrument, Order} from '../../api/bitmex/types';
+import {Exchange} from '../settings/types';
-export const defaultState: WebsocketState = {
+const websocketSlice = {
__keys: {},
instrument: [],
order: [],
@@ -31,102 +29,130 @@ export const defaultState: WebsocketState = {
error: '',
};
+export const defaultState: WebsocketState = {
+ [Exchange.BitMeX]: websocketSlice,
+ [Exchange.BitMeXTEST]: websocketSlice,
+};
+
export const websocketReducer: Reducer = (state = defaultState, action): WebsocketState => {
- switch (action.type) {
- case FETCH_ORDERS:
- return {...state, order: {...state.order, ...action.payload}};
- case REDUX_WEBSOCKET_CONNECT:
- return {...state, wsLoading: true, message: 'Connecting...'};
- case REDUX_WEBSOCKET_OPEN:
- return {...state, wsLoading: false, message: 'Websocket opened.', connected: true};
- case REDUX_WEBSOCKET_BROKEN:
- case REDUX_WEBSOCKET_CLOSED:
- return {...state, ...defaultState, message: 'Websocket closed.'};
- case REDUX_WEBSOCKET_ERROR:
- return {...state, ...defaultState, message: 'Error. Too many reloads?'};
- case REDUX_WEBSOCKET_MESSAGE:
- return reduxWeboscketMessage(state, action);
- case REDUX_WEBSOCKET_SEND:
+ const [exchange, type] = action.type.split('::') as [Exchange, string];
+ const exchangeSlice = state[exchange];
+
+ switch (type) {
+ case FETCH_ORDERS: {
+ const slice = {...exchangeSlice, order: {...exchangeSlice.order, ...action.payload}};
+ return {...state, [exchange]: slice};
+ }
+ case WEBSOCKET_CONNECT: {
+ const slice = {...exchangeSlice, wsLoading: true, message: 'Connecting...'};
+ return {...state, [exchange]: slice};
+ }
+ case WEBSOCKET_OPEN: {
+ const slice = {...exchangeSlice, wsLoading: false, message: 'Websocket opened.', connected: true};
+ return {...state, [exchange]: slice};
+ }
+ case WEBSOCKET_BROKEN:
+ case WEBSOCKET_CLOSED: {
+ const slice = {...defaultState, message: 'Websocket closed.'};
+ return {...state, [exchange]: slice};
+ }
+ case WEBSOCKET_ERROR: {
+ const slice = {...defaultState, message: 'Error. Too many reloads?'};
+ return {...state, [exchange]: slice};
+ }
+ case WEBSOCKET_MESSAGE:
+ return reduxWeboscketMessage(state, action, exchange);
+ case WEBSOCKET_SEND:
default:
return state;
}
};
-const reduxWeboscketMessage: Reducer = (state = defaultState, action): WebsocketState => {
+const reduxWeboscketMessage = (state = defaultState, action: any, exchange: Exchange): WebsocketState => {
const response: WebsocketResponse = JSON.parse(action.payload.message);
const {table, data, action: ws_action, subscribe, status} = response;
+ const exchangeSlice = state[exchange];
if (subscribe) {
const message = response['success'] ? 'Successful subscription.' : 'Error while subscribing...';
- return {...state, message};
+ return {...state, [exchange]: {...exchangeSlice, message}};
} else if (!!status) {
const message = `Websocket. Status: ${response.status || 'Error'}`;
- return {...state, message};
+ return {...state, [exchange]: {...exchangeSlice, message}};
} else if (ws_action) {
switch (ws_action) {
case RESPONSE_ACTIONS.PARTIAL: {
- const updatedTable = [...state[table], ...data];
- const updatedKeys = {...state.__keys, [table]: response.keys};
+ const updatedTable = [...exchangeSlice[table], ...data];
+ const updatedKeys = {...exchangeSlice.__keys, [table]: response.keys};
+ const slice = {...exchangeSlice, [table]: updatedTable, __keys: updatedKeys};
- return {...state, [table]: updatedTable, __keys: updatedKeys};
+ return {...state, [exchange]: slice};
}
case RESPONSE_ACTIONS.INSERT: {
- const updatedTable = [...state[table], ...data];
+ const updatedTable = [...exchangeSlice[table], ...data];
+ const slice = {...exchangeSlice, [table]: updatedTable};
- return {...state, [table]: updatedTable};
+ return {...state, [exchange]: slice};
}
case RESPONSE_ACTIONS.UPDATE: {
- let updatedTable: Instrument[] | Order[] = state[table];
+ let updatedTable: Instrument[] | Order[] = exchangeSlice[table];
for (const key_val of data) {
- const indexUpdate = findItemByKeys(state.__keys[table] as any, updatedTable, key_val as any);
+ const indexUpdate = findItemByKeys(exchangeSlice.__keys[table] as any, updatedTable, key_val as any);
if (indexUpdate === -1) continue;
- const updatedValue: Instrument | Order = {...state[table][indexUpdate], ...key_val};
+ const updatedValue: Instrument | Order = {...exchangeSlice[table][indexUpdate], ...key_val};
updatedTable = [
- ...state[table].slice(0, indexUpdate),
+ ...exchangeSlice[table].slice(0, indexUpdate),
updatedValue,
- ...state[table].slice(indexUpdate + 1),
+ ...exchangeSlice[table].slice(indexUpdate + 1),
] as Instrument[] | Order[];
if (table === 'order') {
const leavesQty = (updatedValue as Order)?.leavesQty;
if (typeof leavesQty === 'number' && leavesQty <= 0) {
- updatedTable = state[table].filter((_, i) => i !== indexUpdate);
+ updatedTable = exchangeSlice[table].filter((_, i) => i !== indexUpdate);
}
}
}
- return {...state, [table]: updatedTable};
+
+ const slice = {...exchangeSlice, [table]: updatedTable};
+ return {...state, [exchange]: slice};
}
}
} else if (!!response?.unsubscribe) {
- const {[response.unsubscribe]: _deleted, ...rest} = state.__keys;
+ const {[response.unsubscribe]: _deleted, ...rest} = exchangeSlice.__keys;
// eslint-disable-next-line no-console
console.warn('UNSUBSCRIBED: ', rest, response.unsubscribe);
- return {...state, __keys: rest};
+ const slice = {...exchangeSlice, __keys: rest};
+ return {...state, [exchange]: slice};
}
return state;
};
-export const wsConnect = (): Thunk => async (dispatch) => {
- try {
- const url = websocketBaseUrl();
- const subscribe = instrumentTopics(SYMBOL.XBTUSD, SYMBOL.ETHUSD, SYMBOL.XRPUSD);
- dispatch(connect(`${url}${subscribe}`));
- } catch (err) {
- // eslint-disable-next-line no-console
- console.log(err.response.data, 'wsConnect Error');
- }
-};
+export const wsConnect =
+ (exchange: Exchange): Thunk =>
+ async (dispatch) => {
+ try {
+ const url = websocketBaseUrl(exchange);
+ const subscribe = instrumentTopics(SYMBOL.XBTUSD, SYMBOL.ETHUSD, SYMBOL.XRPUSD);
+ dispatch(connect(`${url}${subscribe}`, [exchange]));
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.log(err.response.data, 'wsConnect Error');
+ }
+ };
-export const wsDisconnect = (): Thunk => async (dispatch) => {
- try {
- dispatch(disconnect());
- } catch (err) {
- // eslint-disable-next-line no-console
- console.log(err.response.data, 'wsDisconnect Error');
- }
-};
+export const wsDisconnect =
+ (exchange: Exchange): Thunk =>
+ async (dispatch) => {
+ try {
+ dispatch(disconnect(exchange));
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.log(err.response.data, 'wsDisconnect Error');
+ }
+ };
export const wsAuthenticate = (): Thunk => async (dispatch) => {
try {
diff --git a/client/src/redux/selectors/index.ts b/client/src/redux/selectors/index.ts
index 34079461..675189c0 100644
--- a/client/src/redux/selectors/index.ts
+++ b/client/src/redux/selectors/index.ts
@@ -1,9 +1,18 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
-import {createSelector} from '@reduxjs/toolkit';
+import {createSelector, OutputParametricSelector} from '@reduxjs/toolkit';
import {SYMBOL, SIDE, Order} from '../../redux/api/bitmex/types';
import {AppState} from 'redux/modules/state';
import {INSTRUMENT_PARAMS, RegularOrder, StopLoss} from 'utils';
import {parseNumber} from 'general/formatting';
+import {Exchange} from 'redux/modules/settings/types';
+
+function createWebsocketSelector(key: K) {
+ return createSelector(
+ (state: AppState) => state.websocket,
+ (_: AppState, exchange: Exchange) => exchange, // this is the parameter we need
+ (ws, exchange) => ws[exchange][key],
+ );
+}
export interface SymbolPrices {
symbol: SYMBOL;
@@ -22,11 +31,9 @@ const getOrderLoading = ({preview: {previewLoading}}: AppState) => previewLoadin
const getOrderError = ({preview: {error}}: AppState) => error;
export const getBalance = ({preview: {balance}}: AppState) => balance;
-export const table_instrument = ({websocket: {instrument}}: AppState) => instrument;
-const table_order = ({websocket: {order}}: AppState) => order;
-const websocketLoading = ({websocket: {wsLoading}}: AppState) => wsLoading;
-const websocketMessage = ({websocket: {message}}: AppState) => message;
-const websocketConnected = ({websocket: {connected}}: AppState) => connected;
+export const table_instrument = createWebsocketSelector('instrument');
+
+const table_order = createWebsocketSelector('order');
const getTrailingOrderStatus = ({trailing: {trailOrderStatus}}: AppState) => trailOrderStatus;
const getTrailingOrderId = ({trailing: {trailOrderId}}: AppState) => trailOrderId;
@@ -180,3 +187,8 @@ export const groupedOrdersSelector = createSelector([getProfitOrders], (orders):
});
return groupedOrders;
});
+
+export const activeApiKeySelector = createSelector(
+ [(state: AppState) => state.settings.activeApiKeys, (state: AppState) => state.settings.activeExchange],
+ (activeApiKeys, activeExchange) => Boolean(activeExchange && (activeApiKeys?.[activeExchange] ?? false)),
+);
diff --git a/client/src/redux/selectors/selectors.spec.ts b/client/src/redux/selectors/selectors.spec.ts
index 4942c684..d8015d8b 100644
--- a/client/src/redux/selectors/selectors.spec.ts
+++ b/client/src/redux/selectors/selectors.spec.ts
@@ -22,23 +22,28 @@ import {
mockTrailingState,
mockCrossState,
mockOrdersState,
+ mockSettingsState,
} from 'tests/mockData/orders';
import {AppState} from 'redux/modules/state';
+import {Exchange} from 'redux/modules/settings/types';
describe('Selectors', () => {
+ const exchange = Exchange.BitMeX;
+
const mockState: AppState = {
websocket: mockWebsocketState({instrument: mockInstrumentData as Instrument[]}),
preview: mockPreviewState({orders: mockScaledOrders, balance: 12_345_678_993_321, showPreview: true}),
trailing: mockTrailingState({trailOrderSide: SIDE.SELL, trailOrderSymbol: SYMBOL.XBTUSD}),
cross: mockCrossState(),
orders: mockOrdersState(),
+ settings: mockSettingsState(),
};
let result: unknown;
describe('websocketBidAskPrices', () => {
it('should return askPrice of current symbol', () => {
- const instrument = table_instrument(mockState);
+ const instrument = table_instrument(mockState, exchange);
const wsSymbol = getTrailingOrderSymbol(mockState);
const result = websocketBidAskPrices.resultFunc(instrument, wsSymbol);
expect(result!.askPrice).toEqual(8011);
@@ -51,7 +56,7 @@ describe('Selectors', () => {
websocket: {...mockState.websocket},
trailing: {...mockState.trailing, trailOrderSymbol: 'HELLO' as SYMBOL},
};
- const instrument = table_instrument(payload);
+ const instrument = table_instrument(payload, exchange);
const wsSymbol = getTrailingOrderSymbol(payload);
const result = websocketBidAskPrices.resultFunc(instrument, wsSymbol);
expect(result).toEqual(undefined);
@@ -82,7 +87,7 @@ describe('Selectors', () => {
describe('websocketTrailingPriceSelector', () => {
function validateTrailingPrice(symbol: SYMBOL, side: SIDE) {
- const instrument = table_instrument(mockState);
+ const instrument = table_instrument(mockState, exchange);
const bidAskPrices = websocketBidAskPrices.resultFunc(instrument, symbol);
return websocketTrailingPriceSelector.resultFunc(bidAskPrices, side, symbol);
}
diff --git a/client/src/redux/store/index.ts b/client/src/redux/store/index.ts
index dde97d6f..261a2e15 100644
--- a/client/src/redux/store/index.ts
+++ b/client/src/redux/store/index.ts
@@ -1,8 +1,9 @@
-import {combineReducers} from 'redux';
-import {configureStore, getDefaultMiddleware} from '@reduxjs/toolkit';
+import {AnyAction, combineReducers} from 'redux';
+import {useDispatch} from 'react-redux';
+import {configureStore} from '@reduxjs/toolkit';
import reduxWebsocket from '@giantmachines/redux-websocket';
import notificationMiddleware from '../middlewares/toast-notification';
-import {API, APIType} from 'redux/api/api';
+import {ExchangeAPIFacade, ExchangeAPIFacadeType} from 'redux/api/api';
import {AppState} from 'redux/modules/state';
import {previewReducer as preview} from '../modules/preview/previewModule';
@@ -10,20 +11,34 @@ import {websocketReducer as websocket} from '../modules/websocket/websocketModul
import {trailingReducer as trailing} from '../modules/trailing/trailingModule';
import {crossReducer as cross} from '../modules/cross/crossModule';
import {ordersReducer as orders} from '../modules/orders/ordersModule';
+import {settingsReducer as settings, activateExchange} from '../modules/settings/settingsModule';
+import {Exchange} from 'redux/modules/settings/types';
-const rootReducer = combineReducers({preview, websocket, trailing, cross, orders});
+const appReducer = combineReducers({preview, websocket, trailing, cross, orders, settings});
-const reduxWebsocketMiddleware = reduxWebsocket();
+const rootReducer = (state: any, action: AnyAction) => {
+ if (action.type === activateExchange.type) {
+ return appReducer(undefined, action);
+ }
+ return appReducer(state, action);
+};
-function createStore(preloadedState: Partial = {}, api: APIType = new API()) {
+const BitMeX_reduxWebsocketMiddleware = reduxWebsocket({prefix: Exchange.BitMeX});
+const BitMeXTESTNET_reduxWebsocketMiddleware = reduxWebsocket({prefix: Exchange.BitMeXTEST});
+
+function createStore(preloadedState: Partial = {}, api: ExchangeAPIFacadeType = new ExchangeAPIFacade()) {
return configureStore({
reducer: rootReducer,
- middleware: getDefaultMiddleware({
- thunk: {extraArgument: api},
- serializableCheck: {ignoredActionPaths: ['payload', 'meta.timestamp']},
- }).concat([reduxWebsocketMiddleware, notificationMiddleware]),
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ thunk: {extraArgument: api},
+ serializableCheck: {ignoredActionPaths: ['payload', 'meta.timestamp']},
+ }).concat([BitMeX_reduxWebsocketMiddleware, BitMeXTESTNET_reduxWebsocketMiddleware, notificationMiddleware]),
preloadedState,
});
}
-export {createStore, rootReducer};
+export type AppDispatch = ReturnType['dispatch'];
+const useAppDispatch = () => useDispatch();
+
+export {createStore, rootReducer, useAppDispatch};
diff --git a/client/src/setupTests.ts b/client/src/setupTests.ts
index 8bfe56d4..947f81e9 100644
--- a/client/src/setupTests.ts
+++ b/client/src/setupTests.ts
@@ -15,7 +15,16 @@ jest.mock('react-toastify', () => {
global.flushPromises = flushPromises;
jest.mock('./redux/api/api', () => {
+ const BasicAPI = require('./tests/proxy').networkProxy.setNetworkTarget(
+ jest.requireActual('./redux/api/api').BasicAPI,
+ require('./tests/proxy').basicTracker,
+ );
return {
- API: require('./tests/proxy').networkProxy.setNetworkTarget(jest.requireActual('./redux/api/api').API),
+ basicApi: new BasicAPI(),
+ ExchangeAPIFacade: require('./tests/proxy').networkProxy.setNetworkTarget(
+ jest.requireActual('./redux/api/api').ExchangeAPIFacade,
+ require('./tests/proxy').tracker,
+ ),
+ BasicAPI,
};
});
diff --git a/client/src/tests/drivers.tsx b/client/src/tests/drivers.tsx
index f71ee82c..3ace2ad3 100644
--- a/client/src/tests/drivers.tsx
+++ b/client/src/tests/drivers.tsx
@@ -1,13 +1,18 @@
import {hocFacade} from 'influnt';
import {Provider} from 'react-redux';
-import {ModalProvider} from 'context/modal-context';
+import {Router} from 'react-router-dom';
import {ChakraProvider} from '@chakra-ui/react';
+import {MemoryHistory} from 'history';
import {MockedStore} from './mockStore';
+import {ModalProvider} from 'context/modal-context';
+import {AppProvider} from 'context/app-context';
-export const withStore = (store: MockedStore) =>
+export const withStore = (params: {store: MockedStore; history: MemoryHistory}) =>
hocFacade({
providers: [
- [Provider, {props: {store}}], //
+ [Provider, {props: {store: params.store}}], //
+ [Router, {props: {history: params.history}}],
+ AppProvider,
ChakraProvider,
ModalProvider,
],
diff --git a/client/src/tests/helpers.ts b/client/src/tests/helpers.ts
index dcfde077..45cf8cc5 100644
--- a/client/src/tests/helpers.ts
+++ b/client/src/tests/helpers.ts
@@ -1,32 +1,37 @@
-import {REDUX_WEBSOCKET_MESSAGE, REDUX_WEBSOCKET_OPEN} from 'redux/modules/websocket/types';
-import {Inspector, Step} from 'influnt/dist/types';
-import {MockedStore} from './mockStore';
-import {withStore} from './drivers';
+import {ForgedResponse, Inspector, Step} from 'influnt/dist/types';
import {AppState} from 'redux/modules/state';
+import {BasicAPIType} from 'redux/api/api';
+import {InfluntExtraArgs} from './influnt';
+import {Exchange} from 'redux/modules/settings/types';
+import {WEBSOCKET_MESSAGE, WEBSOCKET_OPEN} from '@giantmachines/redux-websocket';
-export function openWebsocket(): Step {
+export function openWebsocket(): Step {
return ({extraArgs}) => {
- extraArgs.dispatch({type: REDUX_WEBSOCKET_OPEN});
+ extraArgs.store.dispatch({type: `${Exchange.BitMeX}::${WEBSOCKET_OPEN}`});
};
}
-export function sendWebsocketMessage(data: D): Step {
+export function sendWebsocketMessage(data: D): Step {
return ({extraArgs}) => {
const message = JSON.stringify(data);
- extraArgs.dispatch({type: REDUX_WEBSOCKET_MESSAGE, payload: {message}});
+ extraArgs.store.dispatch({type: `${Exchange.BitMeX}::${WEBSOCKET_MESSAGE}`, payload: {message}});
};
}
-export function storeActions(): Inspector[number]> {
- return ({extraArgs}) => extraArgs.getActions().map(({type}) => type);
+export function storeActions(): Inspector {
+ return ({extraArgs}) => extraArgs.store.getActions().map(({type}) => type);
+}
+
+export function history(): Inspector {
+ return ({extraArgs}) => extraArgs.history?.location.pathname;
}
export function getState(
moduleKey: K,
key?: keyof AppState[K],
-): Inspector[number]> {
+): Inspector {
return ({extraArgs}) => {
- const module = extraArgs.getState()[moduleKey];
+ const module = extraArgs.store.getState()[moduleKey];
return key ? module[key] : module;
};
}
@@ -34,3 +39,28 @@ export function getState(
export function classNameOf(testID: string): Inspector {
return ({locateAll}) => locateAll(testID).className;
}
+
+function createDeferredPromise(): [Promise, (value: T) => void] {
+ let resolver: (value: T) => void = () => undefined;
+ return [new Promise((resolve) => void (resolver = resolve)), resolver];
+}
+
+export function respondBasic, K extends keyof BasicAPIType>(
+ responseId: K,
+ params: Parameters extends void[] ? [undefined] : P,
+) {
+ return {
+ with>>(response: R): ForgedResponse {
+ const [promise, resolve] = createDeferredPromise();
+ return {
+ id: responseId,
+ _signature: Symbol(responseId),
+ response,
+ promise,
+ resolve: () => resolve(response),
+ //@ts-expect-error
+ params,
+ };
+ },
+ };
+}
diff --git a/client/src/tests/influnt.ts b/client/src/tests/influnt.ts
index a51f27ae..2b4234a8 100644
--- a/client/src/tests/influnt.ts
+++ b/client/src/tests/influnt.ts
@@ -1,19 +1,58 @@
+import * as registerModals from '../context/registerModals';
import {configureInflunt, spyModule} from 'influnt';
import {toast} from 'react-toastify';
import {componentContext, withStore} from './drivers';
import {networkProxy} from './proxy';
+import {ComponentSettings} from 'influnt/dist/types';
+import {createMockedStore} from './mockStore';
+import {createMemoryHistory} from 'history';
+import {Exchange} from 'redux/modules/settings/types';
+
+export type InfluntExtraArgs = Parameters[number];
const toastSpy = spyModule('toast', {
module: toast,
parseArgs: (value: any[]) => value[0]?.props,
});
+const showRegisteredModal = jest.requireActual('../context/registerModals').showRegisteredModal;
+const modalSpy = spyModule('modal', {
+ factory: (logger) => {
+ jest.spyOn(registerModals, 'showRegisteredModal').mockImplementation((...args) => {
+ const [type, props] = args;
+ //@ts-ignore
+ const params = type === 'showGeneralModal' ? [props.title, props.subtitle] : props;
+ logger({[type]: params});
+ return showRegisteredModal(...args);
+ });
+ },
+});
+
export const createRenderer = configureInflunt({
providerHoc: withStore,
- spyModules: [toastSpy],
+ spyModules: [toastSpy, modalSpy],
networkProxy,
});
export const createComponentRenderer = configureInflunt({
providerHoc: componentContext,
});
+
+export const createMainRenderer = >>(
+ component: C,
+ componentSettings: ComponentSettings, Parameters[number]> = {},
+) => {
+ const mainExtraArgs = () => ({
+ store: createMockedStore({
+ settings: {
+ activeExchange: Exchange.BitMeX,
+ activeApiKeys: {bitmex: true, bitmexTEST: false},
+ settingsLoading: false,
+ settingsError: '',
+ getAllApiKeysLoading: false,
+ },
+ }),
+ history: createMemoryHistory(),
+ });
+ return createRenderer(component, {extraArgs: mainExtraArgs, ...componentSettings});
+};
diff --git a/client/src/tests/mockData/orders.ts b/client/src/tests/mockData/orders.ts
index ba1a38c5..9471f1f2 100644
--- a/client/src/tests/mockData/orders.ts
+++ b/client/src/tests/mockData/orders.ts
@@ -3,6 +3,7 @@ import {defaultState as previewDefaultState} from 'redux/modules/preview/preview
import {defaultState as trailingDefaultState} from 'redux/modules/trailing/trailingModule';
import {defaultState as crossDefaultState} from 'redux/modules/cross/crossModule';
import {defaultState as ordersDefaultState} from 'redux/modules/orders/ordersModule';
+import {defaultState as settingsDefaultState} from 'redux/modules/settings/settingsModule';
import {WebsocketState} from 'redux/modules/websocket/types';
import {PreviewState} from 'redux/modules/preview/types';
import {TrailingState} from 'redux/modules/trailing/types';
@@ -11,12 +12,17 @@ import {RegularOrder, ScaledOrder} from 'utils';
import {CrossState} from 'redux/modules/cross/types';
import {Instrument} from 'redux/api/bitmex/types';
import {OrdersState} from 'redux/modules/orders/types';
+import {Exchange, SettingsState} from 'redux/modules/settings/types';
-export const mockWebsocketState = (overrides?: Partial) => ({...websocketDefaultState, ...overrides});
+export const mockWebsocketState = (overrides?: Partial) => ({
+ ...websocketDefaultState,
+ [Exchange.BitMeX]: {...websocketDefaultState['bitmex'], ...overrides},
+});
export const mockPreviewState = (overrides?: Partial) => ({...previewDefaultState, ...overrides});
export const mockTrailingState = (overrides?: Partial) => ({...trailingDefaultState, ...overrides});
export const mockCrossState = (overrides?: Partial) => ({...crossDefaultState, ...overrides});
export const mockOrdersState = (overrides?: Partial) => ({...ordersDefaultState, ...overrides});
+export const mockSettingsState = (overrides?: Partial) => ({...settingsDefaultState, ...overrides});
export const mockCreateOrder = (overrides?: Partial): RegularOrder => ({
symbol: SYMBOL.ETHUSD,
diff --git a/client/src/tests/mockStore.ts b/client/src/tests/mockStore.ts
index ed2fb593..9f26e032 100644
--- a/client/src/tests/mockStore.ts
+++ b/client/src/tests/mockStore.ts
@@ -6,12 +6,13 @@ import {
mockTrailingState,
mockCrossState,
mockOrdersState,
+ mockSettingsState,
} from './mockData/orders';
import {rootReducer} from 'redux/store';
import createStore from './configStore';
import {AppState} from 'redux/modules/state';
import notificationMiddleware from '../redux/middlewares/toast-notification';
-import {API} from 'redux/api/api';
+import {ExchangeAPIFacade} from 'redux/api/api';
const mockedDefaultState: AppState = {
websocket: mockWebsocketState({}),
@@ -19,11 +20,12 @@ const mockedDefaultState: AppState = {
trailing: mockTrailingState({}),
cross: mockCrossState({}),
orders: mockOrdersState({}),
+ settings: mockSettingsState({}),
};
export function createMockedStore(overrideState: Partial = {}) {
const preloadedState = {...mockedDefaultState, ...overrideState};
- const middlewares = [thunk.withExtraArgument(new API()), notificationMiddleware];
+ const middlewares = [thunk.withExtraArgument(new ExchangeAPIFacade()), notificationMiddleware];
const enhancer = compose(applyMiddleware(...middlewares));
return createStore(rootReducer, preloadedState, enhancer);
diff --git a/client/src/tests/proxy.ts b/client/src/tests/proxy.ts
index 844ec6be..a2cd023e 100644
--- a/client/src/tests/proxy.ts
+++ b/client/src/tests/proxy.ts
@@ -1,27 +1,63 @@
import {createNetworkProxy} from 'influnt';
+import {Tracker} from 'influnt/dist/types';
+import {isObject} from 'lodash';
import isEqual from 'lodash/fp/isEqual';
export const networkProxy = createNetworkProxy();
-networkProxy.setTracker((key, mocks, logger, methodName) => {
- return async (...args: unknown[]) => {
- // eslint-disable-next-line no-console
- if (!mocks.length) console.error('No mocks found');
+export const tracker: Tracker = (key, mocks, logger, exchange) => {
+ return (methodName: string) => {
+ return async (...args: any[]) => {
+ // eslint-disable-next-line no-console
+ if (!mocks.length) console.error('No mocks found');
- const matchedMock = mocks.find(({id, params}) => id === methodName && isEqual(params, args));
+ const matchedMock = mocks.find(({id, params}) => {
+ if (isObject(args[0])) {
+ //@ts-ignore
+ delete args[0].exchange;
+ if (Object.keys(args[0]).length === 0) args[0] = undefined;
+ }
+ return id === methodName && isEqual(params, args);
+ });
- if (!matchedMock) {
- console.error(
- `No mock defined for request: ${methodName}:`,
- ...args,
- `\nDefined mocks: `,
- ...mocks.map((mock) => [mock.id, mock.params]),
- );
- }
+ if (!matchedMock) {
+ console.error(
+ `No mock defined for request: ${methodName}:`,
+ ...args,
+ `\nDefined mocks: `,
+ ...mocks.map((mock) => [mock.id, mock.params]),
+ );
+ }
- return matchedMock?.promise.then((value) => {
- logger(matchedMock.id, matchedMock.params);
- return value;
- });
+ return matchedMock?.promise.then((value) => {
+ logger(matchedMock.id, matchedMock.params);
+ return value;
+ });
+ };
};
-});
+};
+
+export const basicTracker: Tracker = (key, mocks, logger, ...args) => {
+ const matchedMock = mocks.find(({id, params}) => {
+ if (isObject(args[0])) {
+ //@ts-ignore
+ delete args[0].exchange;
+ if (Object.keys(args[0]).length === 0) args[0] = undefined;
+ }
+ return id === key && isEqual(params, args);
+ });
+
+ if (!matchedMock) {
+ console.error(
+ `No mock defined for request: ${key as string}:`,
+ ...args,
+ `\nDefined mocks: `,
+ ...mocks.map((mock) => [mock.id, mock.params]),
+ );
+ }
+
+ return matchedMock?.promise.then((value) => {
+ logger(matchedMock.id, matchedMock.params);
+ return value;
+ });
+};