diff --git a/frontend/package.json b/frontend/package.json index c992357..b4e93c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,6 @@ "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", - "@types/react-table": "^7.7.2", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.30.0", "babel-eslint": "^10.1.0", @@ -44,13 +43,14 @@ "react-router-dom": "=6.0.0-beta.2", "react-scripts": "^4.0.3", "react-sse-hooks": "^1.0.5", - "react-table": "^7.7.0", + "react-table": "7.7.0", "typescript": "^4.4.2", "vite-jest": "^0.0.3", "web-vitals": "^1.0.1" }, "devDependencies": { "@types/eslint": "^7", + "@types/react-table": "7.7.3", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@vitejs/plugin-react-refresh": "^1.3.1", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 8da3fde..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -button { - font-size: calc(10px + 2vmin); -} diff --git a/frontend/src/Maker.tsx b/frontend/src/Maker.tsx index 788297b..37eb215 100644 --- a/frontend/src/Maker.tsx +++ b/frontend/src/Maker.tsx @@ -1,9 +1,24 @@ -import { Button, Container, Flex, Grid, GridItem, HStack, Stack, Text, useToast, VStack } from "@chakra-ui/react"; +import { + Button, + Container, + Flex, + Grid, + GridItem, + HStack, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + useToast, + VStack, +} from "@chakra-ui/react"; import React, { useState } from "react"; import { useAsync } from "react-async"; import { useEventSource } from "react-sse-hooks"; -import "./App.css"; -import CfdTile from "./components/CfdTile"; +import { CfdTable } from "./components/cfdtables/CfdTable"; +import { CfdTableMaker } from "./components/cfdtables/CfdTableMaker"; import CurrencyInputField from "./components/CurrencyInputField"; import useLatestEvent from "./components/Hooks"; import OrderTile from "./components/OrderTile"; @@ -14,7 +29,8 @@ import { CfdSellOrderPayload, postCfdSellOrderRequest } from "./MakerClient"; export default function App() { let source = useEventSource({ source: "/api/feed", options: { withCredentials: true } }); - const cfds = useLatestEvent(source, "cfds"); + const cfdsOrUndefined = useLatestEvent(source, "cfds"); + let cfds = cfdsOrUndefined ? cfdsOrUndefined! : []; const order = useLatestEvent(source, "order"); console.log(cfds); @@ -47,21 +63,21 @@ export default function App() { }, }); + const runningStates = ["Accepted", "Contract Setup", "Pending Open"]; + const running = cfds.filter((value) => runningStates.includes(value.state)); + const openStates = ["Requested"]; + const open = cfds.filter((value) => openStates.includes(value.state)); + const closedStates = ["Rejected", "Closed"]; + const closed = cfds.filter((value) => closedStates.includes(value.state)); + // TODO: remove this. It just helps to detect immediately if we missed a state. + const unsorted = cfds.filter((value) => + !runningStates.includes(value.state) && !closedStates.includes(value.state) && !openStates.includes(value.state) + ); + return ( - - - {cfds && cfds.map((cfd, index) => - - )} - - - + @@ -111,11 +127,36 @@ export default function App() { > {order ? "Update Sell Order" : "Create Sell Order"} - {order && } + + {order && } + + + + Running [{running.length}] + Open [{open.length}] + Closed [{closed.length}] + Unsorted [{unsorted.length}] (should be empty) + + + + + + + + + + + + + + + + + ); } diff --git a/frontend/src/Taker.tsx b/frontend/src/Taker.tsx index b42d920..6f2f33a 100644 --- a/frontend/src/Taker.tsx +++ b/frontend/src/Taker.tsx @@ -1,9 +1,23 @@ -import { Button, Container, Flex, Grid, GridItem, HStack, Stack, Text, useToast, VStack } from "@chakra-ui/react"; +import { + Button, + Container, + Flex, + Grid, + GridItem, + HStack, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + useToast, + VStack, +} from "@chakra-ui/react"; import React, { useState } from "react"; import { useAsync } from "react-async"; import { useEventSource } from "react-sse-hooks"; -import "./App.css"; -import CfdTile from "./components/CfdTile"; +import { CfdTable } from "./components/cfdtables/CfdTable"; import CurrencyInputField from "./components/CurrencyInputField"; import useLatestEvent from "./components/Hooks"; import { Cfd, Order, WalletInfo } from "./components/Types"; @@ -45,7 +59,8 @@ async function getMargin(payload: MarginRequestPayload): Promise export default function App() { let source = useEventSource({ source: "/api/feed" }); - const cfds = useLatestEvent(source, "cfds"); + const cfdsOrUndefined = useLatestEvent(source, "cfds"); + let cfds = cfdsOrUndefined ? cfdsOrUndefined! : []; const order = useLatestEvent(source, "order"); const walletInfo = useLatestEvent(source, "wallet"); @@ -93,81 +108,98 @@ export default function App() { }, }); + const runningStates = ["Request sent", "Requested", "Contract Setup", "Pending Open"]; + const running = cfds.filter((value) => runningStates.includes(value.state)); + const closedStates = ["Rejected", "Closed"]; + const closed = cfds.filter((value) => closedStates.includes(value.state)); + // TODO: remove this. It just helps to detect immediately if we missed a state. + const unsorted = cfds.filter((value) => + !runningStates.includes(value.state) && !closedStates.includes(value.state) + ); + return ( - - - - {cfds && cfds.map((cfd, index) => - - )} - - - - - - - {/*TODO: Do we need this? does it make sense to only display the price from the order?*/} - Current Price (Kraken): - tbd - - - Order Price: - {order?.price} - - - Quantity: - { - setQuantity(parse(valueString)); - - if (!order) { - return; - } - - let quantity = valueString ? Number.parseFloat(valueString) : 0; - let payload: MarginRequestPayload = { - leverage: order.leverage, - price: order.price, - quantity, + + + + + + + {/*TODO: Do we need this? does it make sense to only display the price from the order?*/} + Current Price (Kraken): + tbd + + + Order Price: + {order?.price} + + + Quantity: + { + setQuantity(parse(valueString)); + if (!order) { + return; + } + let quantity = valueString ? Number.parseFloat(valueString) : 0; + let payload: MarginRequestPayload = { + leverage: order.leverage, + price: order.price, + quantity, + }; + calculateMargin(payload); + }} + value={format(quantity)} + /> + + + Margin in BTC: + {margin} + + Leverage: + {/* TODO: consider button group */} + + + + + + { - - - - {} - - - + > + BUY + } + + + + + + Running [{running.length}] + Closed [{closed.length}] + Unsorted [{unsorted.length}] (should be empty) + + + + + + + + + + + + + + + ); } diff --git a/frontend/src/components/CfdTile.tsx b/frontend/src/components/CfdTile.tsx deleted file mode 100644 index 00873d5..0000000 --- a/frontend/src/components/CfdTile.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Box, Button, HStack, SimpleGrid, Text, useToast, VStack } from "@chakra-ui/react"; -import React from "react"; -import { useAsync } from "react-async"; -import { postAcceptOrder, postRejectOrder } from "../MakerClient"; -import { Cfd, unixTimestampToDate } from "./Types"; - -interface CfdTileProps { - index: number; - cfd: Cfd; -} - -export default function CfdTile( - { - index, - cfd, - }: CfdTileProps, -) { - const toast = useToast(); - - let { run: acceptOrder, isLoading: isAccepting } = useAsync({ - deferFn: async ([args]: any[]) => { - try { - let payload = { - order_id: args.order_id, - }; - await postAcceptOrder(payload); - } catch (e) { - const description = typeof e === "string" ? e : JSON.stringify(e); - - toast({ - title: "Error", - description, - status: "error", - duration: 9000, - isClosable: true, - }); - } - }, - }); - - let { run: rejectOrder, isLoading: isRejecting } = useAsync({ - deferFn: async ([args]: any[]) => { - try { - let payload = { - order_id: args.order_id, - }; - await postRejectOrder(payload); - } catch (e) { - const description = typeof e === "string" ? e : JSON.stringify(e); - - toast({ - title: "Error", - description, - status: "error", - duration: 9000, - isClosable: true, - }); - } - }, - }); - - let actionButtons; - if (cfd.state === "Open") { - actionButtons = - - ; - } else if (cfd.state == "Requested") { - actionButtons = ( - - - - - - - - - ); - } - - return ( - - - - CFD #{index} - - - Trading Pair - {cfd.trading_pair} - Position - {cfd.position} - CFD Price - {cfd.initial_price} - Leverage - {cfd.leverage} - Quantity - {cfd.quantity_usd} - Margin - {cfd.margin} - Liquidation Price - - {cfd.liquidation_price} - - Profit - {cfd.profit_usd} - Open since - {/* TODO: Format date in a more compact way */} - - {unixTimestampToDate(cfd.state_transition_timestamp).toString()} - - Status - {cfd.state} - - {actionButtons} - - - ); -} diff --git a/frontend/src/components/Wallet.tsx b/frontend/src/components/Wallet.tsx index d3e0694..3b68332 100644 --- a/frontend/src/components/Wallet.tsx +++ b/frontend/src/components/Wallet.tsx @@ -22,7 +22,7 @@ export default function Wallet( balance = {walletInfo.balance} BTC; address = ( - {walletInfo.address} + {walletInfo.address} : } @@ -30,7 +30,16 @@ export default function Wallet( /> ); - timestamp = {unixTimestampToDate(walletInfo.last_updated_at).toString()}; + timestamp = + Updated: {unixTimestampToDate(walletInfo.last_updated_at).toLocaleDateString("en-US", { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + ; } return ( diff --git a/frontend/src/components/cfdtables/CfdTable.tsx b/frontend/src/components/cfdtables/CfdTable.tsx new file mode 100644 index 0000000..52899fe --- /dev/null +++ b/frontend/src/components/cfdtables/CfdTable.tsx @@ -0,0 +1,271 @@ +import { ChevronRightIcon, ChevronUpIcon, TriangleDownIcon, TriangleUpIcon } from "@chakra-ui/icons"; +import { Badge, Box, chakra, HStack, IconButton, Table as CUITable, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"; +import React from "react"; +import { Column, Row, useExpanded, useSortBy, useTable } from "react-table"; +import { Cfd } from "../Types"; + +interface CfdTableProps { + data: Cfd[]; +} + +export function CfdTable( + { data }: CfdTableProps, +) { + const tableData = React.useMemo( + () => data, + [data], + ); + + const columns: Array> = React.useMemo( + () => [ + { + id: "expander", + Header: () => null, + Cell: ({ row }: any) => ( + + {row.isExpanded + ? } + onClick={() => { + row.toggleRowExpanded(); + }} + /> + : } + onClick={() => { + row.toggleRowExpanded(); + }} + />} + + ), + }, + { + Header: "OrderId", + accessor: "order_id", // accessor is the "key" in the data + }, + { + Header: "Position", + accessor: ({ position }) => { + let colorScheme = "green"; + if (position.toLocaleLowerCase() === "buy") { + colorScheme = "purple"; + } + return ( + {position} + ); + }, + isNumeric: true, + }, + + { + Header: "Quantity", + accessor: ({ quantity_usd }) => { + return (); + }, + isNumeric: true, + }, + { + Header: "Leverage", + accessor: "leverage", + isNumeric: true, + }, + { + Header: "Margin", + accessor: "margin", + isNumeric: true, + }, + { + Header: "Initial Price", + accessor: ({ initial_price }) => { + return (); + }, + isNumeric: true, + }, + { + Header: "Liquidation Price", + isNumeric: true, + accessor: ({ liquidation_price }) => { + return (); + }, + }, + { + Header: "Unrealized P/L", + accessor: ({ profit_usd }) => { + return (); + }, + isNumeric: true, + }, + { + Header: "Timestamp", + accessor: "state_transition_timestamp", + }, + { + Header: "State", + accessor: ({ state }) => { + let colorScheme = "gray"; + if (state.toLowerCase() === "rejected") { + colorScheme = "red"; + } + if (state.toLowerCase() === "contract setup") { + colorScheme = "green"; + } + return ( + {state} + ); + }, + }, + ], + [], + ); + + // if we mark certain columns only as hidden, they are still around and we can render them in the sub-row + const hiddenColumns = ["order_id", "leverage", "state_transition_timestamp"]; + + return ( + + ); +} + +function renderRowSubComponent(row: Row) { + // TODO: I would show additional information here such as txids, timestamps, actions + let cells = row.allCells + .filter((cell) => { + return ["state_transition_timestamp"].includes(cell.column.id); + }) + .map((cell) => { + return cell; + }); + + return ( + <> + Showing some more information here... + + {cells.map(cell => ( + + {cell.column.id} = {cell.render("Cell")} + + ))} + + + ); +} + +interface DollarsProps { + amount: number; +} +function Dollars({ amount }: DollarsProps) { + const price = Math.floor(amount * 100.0) / 100.0; + return ( + <> + $ {price} + + ); +} + +interface TableProps { + columns: Array>; + tableData: Cfd[]; + hiddenColumns: string[]; + renderDetails: any; +} + +export function Table({ columns, tableData, hiddenColumns, renderDetails }: TableProps) { + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + visibleColumns, + } = useTable( + { + columns, + data: tableData, + initialState: { + hiddenColumns, + }, + }, + useSortBy, + useExpanded, + ); + + return ( + <> + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} + + ))} + + + {rows.map((row) => { + prepareRow(row); + return ( + + + // @ts-ignore + row.toggleRowExpanded()} + > + {row.cells.map((cell) => ( + // @ts-ignore + + ))} + + + {// @ts-ignore + row.isExpanded + ? ( + + + + + ) + : null} + + ); + })} + + + + ); +} diff --git a/frontend/src/components/cfdtables/CfdTableMaker.tsx b/frontend/src/components/cfdtables/CfdTableMaker.tsx new file mode 100644 index 0000000..a7951f0 --- /dev/null +++ b/frontend/src/components/cfdtables/CfdTableMaker.tsx @@ -0,0 +1,236 @@ +import { CheckIcon, ChevronRightIcon, ChevronUpIcon, CloseIcon } from "@chakra-ui/icons"; +import { Badge, Box, HStack, IconButton, useToast } from "@chakra-ui/react"; +import React from "react"; +import { useAsync } from "react-async"; +import { Column, Row } from "react-table"; +import { postAcceptOrder, postRejectOrder } from "../../MakerClient"; +import { Cfd } from "../Types"; +import { Table } from "./CfdTable"; + +interface CfdMableTakerProps { + data: Cfd[]; +} + +export function CfdTableMaker( + { data }: CfdMableTakerProps, +) { + const tableData = React.useMemo( + () => data, + [data], + ); + + const toast = useToast(); + + let { run: acceptOrder, isLoading: isAccepting } = useAsync({ + deferFn: async ([orderId]: any[]) => { + try { + let payload = { + order_id: orderId, + }; + await postAcceptOrder(payload); + } catch (e) { + const description = typeof e === "string" ? e : JSON.stringify(e); + + toast({ + title: "Error", + description, + status: "error", + duration: 9000, + isClosable: true, + }); + } + }, + }); + + let { run: rejectOrder, isLoading: isRejecting } = useAsync({ + deferFn: async ([orderId]: any[]) => { + try { + let payload = { + order_id: orderId, + }; + await postRejectOrder(payload); + } catch (e) { + const description = typeof e === "string" ? e : JSON.stringify(e); + + toast({ + title: "Error", + description, + status: "error", + duration: 9000, + isClosable: true, + }); + } + }, + }); + + const columns: Array> = React.useMemo( + () => [ + { + id: "expander", + Header: () => null, + Cell: ({ row }: any) => ( + + {row.isExpanded + ? } + onClick={() => { + row.toggleRowExpanded(); + }} + /> + : } + onClick={() => { + row.toggleRowExpanded(); + }} + />} + + ), + }, + { + Header: "OrderId", + accessor: "order_id", + }, + { + Header: "Position", + accessor: ({ position }) => { + let colorScheme = "green"; + if (position.toLocaleLowerCase() === "buy") { + colorScheme = "purple"; + } + return ( + {position} + ); + }, + isNumeric: true, + }, + + { + Header: "Quantity", + accessor: ({ quantity_usd }) => { + return (); + }, + isNumeric: true, + }, + { + Header: "Leverage", + accessor: "leverage", + isNumeric: true, + }, + { + Header: "Margin", + accessor: "margin", + isNumeric: true, + }, + { + Header: "Initial Price", + accessor: ({ initial_price }) => { + return (); + }, + isNumeric: true, + }, + { + Header: "Liquidation Price", + isNumeric: true, + accessor: ({ liquidation_price }) => { + return (); + }, + }, + { + Header: "Unrealized P/L", + accessor: ({ profit_usd }) => { + return (); + }, + isNumeric: true, + }, + { + Header: "Timestamp", + accessor: "state_transition_timestamp", + }, + { + Header: "Action", + accessor: ({ state, order_id }) => { + if (state.toLowerCase() === "requested") { + return ( + } + onClick={async () => acceptOrder(order_id)} + isLoading={isAccepting} + /> + } + onClick={async () => rejectOrder(order_id)} + isLoading={isRejecting} + /> + ); + } + + let colorScheme = "gray"; + if (state.toLowerCase() === "rejected") { + colorScheme = "red"; + } + if (state.toLowerCase() === "contract setup") { + colorScheme = "green"; + } + return ( + {state} + ); + }, + }, + ], + [], + ); + + // if we mark certain columns only as hidden, they are still around and we can render them in the sub-row + const hiddenColumns = ["order_id", "leverage", "Unrealized P/L", "state_transition_timestamp"]; + + return ( +
+ {column.render("Header")} + + {// @ts-ignore + column.isSorted + ? ( + // @ts-ignore + column.isSortedDesc + ? ( + + ) + : ( + + ) + ) + : null} + +
+ {cell.render("Cell")} +
+ + {renderDetails(row)} +
+ ); +} + +function renderRowSubComponent(row: Row) { + // TODO: I would show additional information here such as txids, timestamps, actions + let cells = row.allCells + .filter((cell) => { + return ["state_transition_timestamp"].includes(cell.column.id); + }) + .map((cell) => { + return cell; + }); + + return ( + <> + Showing some more information here... + + {cells.map(cell => ( + + {cell.column.id} = {cell.render("Cell")} + + ))} + + + ); +} + +interface DollarsProps { + amount: number; +} +function Dollars({ amount }: DollarsProps) { + const price = Math.floor(amount * 100.0) / 100.0; + return ( + <> + $ {price} + + ); +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 65989a8..732c141 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2800,10 +2800,10 @@ dependencies: "@types/react" "*" -"@types/react-table@^7.7.2": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.7.2.tgz#434f8230eb011c7eed8f3550fdf25befafebcfac" - integrity sha512-NwB78t3YV5pZ1NK3m2vylb/d0DKVyWH4y4GMCtlE4tg2n5ENM4ejzKnT46YKuqG2cPjWc+PIxuRVMd5OYX1z4A== +"@types/react-table@7.7.3": + version "7.7.3" + resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.7.3.tgz#0e5f952ec8562db1f6c950c766b53f27294a7e89" + integrity sha512-IL9DsA+V9AXUSPT6L+fFjo6YfEV40Fb+WmbrVxn+TjsPYUjkMZ0EZP1q0lTiosdrbrq3TeQI35Naxqc0ZTWEQg== dependencies: "@types/react" "*" @@ -10833,7 +10833,7 @@ react-style-singleton@^2.1.0: invariant "^2.2.4" tslib "^1.0.0" -react-table@^7.7.0: +react-table@7.7.0: version "7.7.0" resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912" integrity sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==