Browse Source

UI state mapping & dynamic actions

All display related decisions are taken in the UI, but on top of the UI's model.
For this purpose we introduce classes for `CfdState` and `Position` so we can add the relevant mapping functions to these classes.
To achieve the mapping from daemon sse even to the `Cfd` / `Order` interface (that now contain classes instead of just primitives) we extend the sse hook to accept a mapping function.
We define this mapping function for `Cfd` and `Order`, because those contain classes, for all others we just use the default mapping.

Actions are dynamically rendered based on the state.
The daemon decides on the action name.
A single post endpoint handles all actions.
The UI maps the actions to icons.

Co-authored-by: Thomas Eizinger <thomas@coblox.tech>
fix-olivia-event-id
Daniel Karzel 3 years ago
parent
commit
a2fbfedc02
No known key found for this signature in database GPG Key ID: 30C3FC2E438ADB6E
  1. 10
      Cargo.lock
  2. 1
      daemon/Cargo.toml
  3. 3
      daemon/src/maker.rs
  4. 12
      daemon/src/model/cfd.rs
  5. 51
      daemon/src/routes_maker.rs
  6. 99
      daemon/src/to_sse_event.rs
  7. 46
      frontend/src/MakerApp.tsx
  8. 18
      frontend/src/MakerClient.tsx
  9. 27
      frontend/src/TakerApp.tsx
  10. 13
      frontend/src/components/Hooks.tsx
  11. 164
      frontend/src/components/Types.tsx
  12. 104
      frontend/src/components/cfdtables/CfdTable.tsx
  13. 236
      frontend/src/components/cfdtables/CfdTableMaker.tsx

10
Cargo.lock

@ -507,6 +507,7 @@ dependencies = [
"rust_decimal_macros",
"serde",
"serde_json",
"serde_plain",
"serde_with",
"sha2",
"sqlx",
@ -2154,6 +2155,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_plain"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95455e7e29fada2052e72170af226fbe368a4ca33dee847875325d9fdb133858"
dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "1.10.0"

1
daemon/Cargo.toml

@ -23,6 +23,7 @@ rust_decimal = { version = "1.16", features = ["serde-float", "serde-arbitrary-p
rust_decimal_macros = "1.16"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_plain = "1"
serde_with = { version = "1", features = ["macros"] }
sha2 = "0.9"
sqlx = { version = "0.5", features = ["offline"] }

3
daemon/src/maker.rs

@ -209,8 +209,7 @@ async fn main() -> Result<()> {
rocket::routes![
routes_maker::maker_feed,
routes_maker::post_sell_order,
routes_maker::post_accept_order,
routes_maker::post_reject_order,
routes_maker::post_cfd_action,
routes_maker::get_health_check
],
)

12
daemon/src/model/cfd.rs

@ -6,6 +6,7 @@ use bdk::bitcoin::{Address, Amount, PublicKey, Transaction};
use bdk::descriptor::Descriptor;
use cfd_protocol::secp256k1_zkp::{EcdsaAdaptorSignature, SECP256K1};
use cfd_protocol::{finalize_spend_transaction, spending_tx_sighash};
use rocket::request::FromParam;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
@ -29,6 +30,15 @@ impl Display for OrderId {
}
}
impl<'v> FromParam<'v> for OrderId {
type Error = uuid::Error;
fn from_param(param: &'v str) -> Result<Self, Self::Error> {
let uuid = param.parse::<Uuid>()?;
Ok(OrderId(uuid))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Origin {
Ours,
@ -293,7 +303,7 @@ impl Display for CfdState {
write!(f, "Refunded")
}
CfdState::SetupFailed { .. } => {
write!(f, "Safely Aborted")
write!(f, "Setup Failed")
}
}
}

51
daemon/src/routes_maker.rs

@ -2,7 +2,7 @@ use crate::auth::Authenticated;
use crate::model::cfd::{Cfd, Order, OrderId, Origin};
use crate::model::{Usd, WalletInfo};
use crate::routes::EmbeddedFileExt;
use crate::to_sse_event::{CfdsWithCurrentPrice, ToSseEvent};
use crate::to_sse_event::{CfdAction, CfdsWithCurrentPrice, ToSseEvent};
use crate::{bitmex_price_feed, maker_cfd};
use anyhow::Result;
use rocket::http::{ContentType, Header, Status};
@ -127,40 +127,33 @@ pub struct PromptAuthentication {
/// The maker POSTs this to accept an order
#[derive(Debug, Clone, Deserialize)]
pub struct AcceptOrRejectOrderRequest {
pub struct AcceptOrRejectTakeRequest {
pub order_id: OrderId,
}
#[rocket::post("/order/accept", data = "<cfd_accept_order_request>")]
pub async fn post_accept_order(
cfd_accept_order_request: Json<AcceptOrRejectOrderRequest>,
#[rocket::post("/cfd/<id>/<action>")]
pub async fn post_cfd_action(
id: OrderId,
action: CfdAction,
cfd_actor_address: &State<Address<maker_cfd::Actor>>,
_auth: Authenticated,
) -> status::Accepted<()> {
cfd_actor_address
.do_send_async(maker_cfd::AcceptOrder {
order_id: cfd_accept_order_request.order_id,
})
.await
.expect("actor to always be available");
status::Accepted(None)
}
#[rocket::post("/order/reject", data = "<cfd_reject_order_request>")]
pub async fn post_reject_order(
cfd_reject_order_request: Json<AcceptOrRejectOrderRequest>,
cfd_actor_address: &State<Address<maker_cfd::Actor>>,
_auth: Authenticated,
) -> status::Accepted<()> {
cfd_actor_address
.do_send_async(maker_cfd::RejectOrder {
order_id: cfd_reject_order_request.order_id,
})
.await
.expect("actor to always be available");
) -> Result<status::Accepted<()>, status::BadRequest<String>> {
match action {
CfdAction::Accept => {
cfd_actor_address
.do_send_async(maker_cfd::AcceptOrder { order_id: id })
.await
.expect("actor to always be available");
}
CfdAction::Reject => {
cfd_actor_address
.do_send_async(maker_cfd::RejectOrder { order_id: id })
.await
.expect("actor to always be available");
}
}
status::Accepted(None)
Ok(status::Accepted(None))
}
#[rocket::get("/alive")]

99
daemon/src/to_sse_event.rs

@ -2,8 +2,9 @@ use crate::model::cfd::OrderId;
use crate::model::{Leverage, Position, TradingPair, Usd};
use crate::{bitmex_price_feed, model};
use bdk::bitcoin::Amount;
use rocket::request::FromParam;
use rocket::response::stream::Event;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize)]
@ -25,10 +26,42 @@ pub struct Cfd {
pub profit_btc: Amount,
pub profit_usd: Usd,
pub state: String,
pub state: CfdState,
pub actions: Vec<CfdAction>,
pub state_transition_timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum CfdAction {
Accept,
Reject,
}
impl<'v> FromParam<'v> for CfdAction {
type Error = serde_plain::Error;
fn from_param(param: &'v str) -> Result<Self, Self::Error> {
let action = serde_plain::from_str(param)?;
Ok(action)
}
}
#[derive(Debug, Clone, Serialize)]
pub enum CfdState {
OutgoingOrderRequest,
IncomingOrderRequest,
Accepted,
Rejected,
ContractSetup,
PendingOpen,
Open,
OpenCommitted,
MustRefund,
Refunded,
SetupFailed,
}
#[derive(Debug, Clone, Serialize)]
pub struct CfdOrder {
pub id: OrderId,
@ -79,7 +112,8 @@ impl ToSseEvent for CfdsWithCurrentPrice {
quantity_usd: cfd.quantity_usd,
profit_btc,
profit_usd,
state: cfd.state.to_string(),
state: cfd.state.clone().into(),
actions: actions_for_state(cfd.state.clone()),
state_transition_timestamp: cfd
.state
.get_transition_timestamp()
@ -141,6 +175,24 @@ impl ToSseEvent for model::WalletInfo {
}
}
impl From<model::cfd::CfdState> for CfdState {
fn from(cfd_state: model::cfd::CfdState) -> Self {
match cfd_state {
model::cfd::CfdState::OutgoingOrderRequest { .. } => CfdState::OutgoingOrderRequest,
model::cfd::CfdState::IncomingOrderRequest { .. } => CfdState::IncomingOrderRequest,
model::cfd::CfdState::Accepted { .. } => CfdState::Accepted,
model::cfd::CfdState::Rejected { .. } => CfdState::Rejected,
model::cfd::CfdState::ContractSetup { .. } => CfdState::ContractSetup,
model::cfd::CfdState::PendingOpen { .. } => CfdState::PendingOpen,
model::cfd::CfdState::Open { .. } => CfdState::Open,
model::cfd::CfdState::OpenCommitted { .. } => CfdState::OpenCommitted,
model::cfd::CfdState::MustRefund { .. } => CfdState::MustRefund,
model::cfd::CfdState::Refunded { .. } => CfdState::Refunded,
model::cfd::CfdState::SetupFailed { .. } => CfdState::SetupFailed,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Quote {
bid: Usd,
@ -165,3 +217,44 @@ fn into_unix_secs(time: SystemTime) -> u64 {
.expect("timestamp to be convertible to duration since epoch")
.as_secs()
}
fn actions_for_state(state: model::cfd::CfdState) -> Vec<CfdAction> {
if let model::cfd::CfdState::IncomingOrderRequest { .. } = state {
vec![CfdAction::Accept, CfdAction::Reject]
} else {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_snapshot_test() {
// Make sure to update the UI after changing this test!
let json = serde_json::to_string(&CfdState::OutgoingOrderRequest).unwrap();
assert_eq!(json, "\"OutgoingOrderRequest\"");
let json = serde_json::to_string(&CfdState::IncomingOrderRequest).unwrap();
assert_eq!(json, "\"IncomingOrderRequest\"");
let json = serde_json::to_string(&CfdState::Accepted).unwrap();
assert_eq!(json, "\"Accepted\"");
let json = serde_json::to_string(&CfdState::Rejected).unwrap();
assert_eq!(json, "\"Rejected\"");
let json = serde_json::to_string(&CfdState::ContractSetup).unwrap();
assert_eq!(json, "\"ContractSetup\"");
let json = serde_json::to_string(&CfdState::PendingOpen).unwrap();
assert_eq!(json, "\"PendingOpen\"");
let json = serde_json::to_string(&CfdState::Open).unwrap();
assert_eq!(json, "\"Open\"");
let json = serde_json::to_string(&CfdState::OpenCommitted).unwrap();
assert_eq!(json, "\"OpenCommitted\"");
let json = serde_json::to_string(&CfdState::MustRefund).unwrap();
assert_eq!(json, "\"MustRefund\"");
let json = serde_json::to_string(&CfdState::Refunded).unwrap();
assert_eq!(json, "\"Refunded\"");
let json = serde_json::to_string(&CfdState::SetupFailed).unwrap();
assert_eq!(json, "\"SetupFailed\"");
}
}

46
frontend/src/MakerApp.tsx

@ -19,24 +19,30 @@ import React, { useState } from "react";
import { useAsync } from "react-async";
import { useEventSource } from "react-sse-hooks";
import { CfdTable } from "./components/cfdtables/CfdTable";
import { CfdTableMaker } from "./components/cfdtables/CfdTableMaker";
import CurrencyInputField from "./components/CurrencyInputField";
import CurrentPrice from "./components/CurrentPrice";
import useLatestEvent from "./components/Hooks";
import OrderTile from "./components/OrderTile";
import { Cfd, Order, PriceInfo, WalletInfo } from "./components/Types";
import {
Cfd,
intoCfd,
intoOrder,
Order,
Position,
PriceInfo,
State,
StateGroupKey,
WalletInfo,
} from "./components/Types";
import Wallet from "./components/Wallet";
import { CfdSellOrderPayload, postCfdSellOrderRequest } from "./MakerClient";
export default function App() {
let source = useEventSource({ source: "/api/feed", options: { withCredentials: true } });
const cfdsOrUndefined = useLatestEvent<Cfd[]>(source, "cfds");
const cfdsOrUndefined = useLatestEvent<Cfd[]>(source, "cfds", intoCfd);
let cfds = cfdsOrUndefined ? cfdsOrUndefined! : [];
const order = useLatestEvent<Order>(source, "order");
console.log(cfds);
const order = useLatestEvent<Order>(source, "order", intoOrder);
const walletInfo = useLatestEvent<WalletInfo>(source, "wallet");
const priceInfo = useLatestEvent<PriceInfo>(source, "quote");
@ -66,16 +72,10 @@ 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)
);
const acceptOrReject = cfds.filter((value) => value.state.getGroup() === StateGroupKey.ACCEPT_OR_REJECT);
const opening = cfds.filter((value) => value.state.getGroup() === StateGroupKey.OPENING);
const open = cfds.filter((value) => value.state.getGroup() === StateGroupKey.OPEN);
const closed = cfds.filter((value) => value.state.getGroup() === StateGroupKey.CLOSED);
return (
<Container maxWidth="120ch" marginTop="1rem">
@ -149,24 +149,24 @@ export default function App() {
<Tabs marginTop={5}>
<TabList>
<Tab>Running [{running.length}]</Tab>
<Tab>Open [{open.length}]</Tab>
<Tab>Accept / Reject [{acceptOrReject.length}]</Tab>
<Tab>Opening [{opening.length}]</Tab>
<Tab>Closed [{closed.length}]</Tab>
<Tab>Unsorted [{unsorted.length}] (should be empty)</Tab>
</TabList>
<TabPanels>
<TabPanel>
<CfdTable data={running} />
<CfdTable data={open} />
</TabPanel>
<TabPanel>
<CfdTableMaker data={open} />
<CfdTable data={acceptOrReject} />
</TabPanel>
<TabPanel>
<CfdTable data={closed} />
<CfdTable data={opening} />
</TabPanel>
<TabPanel>
<CfdTable data={unsorted} />
<CfdTable data={closed} />
</TabPanel>
</TabPanels>
</Tabs>

18
frontend/src/MakerClient.tsx

@ -4,10 +4,6 @@ export interface CfdSellOrderPayload {
max_quantity: number;
}
export interface AcceptOrderRequestPayload {
order_id: string;
}
export async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) {
let res = await fetch(`/api/order/sell`, {
method: "POST",
@ -23,17 +19,3 @@ export async function postCfdSellOrderRequest(payload: CfdSellOrderPayload) {
throw new Error("failed to publish new order");
}
}
export async function postAcceptOrder(payload: AcceptOrderRequestPayload) {
let res = await fetch(
`/api/order/accept`,
{ method: "POST", body: JSON.stringify(payload), credentials: "include" },
);
}
export async function postRejectOrder(payload: AcceptOrderRequestPayload) {
let res = await fetch(
`/api/order/reject`,
{ method: "POST", body: JSON.stringify(payload), credentials: "include" },
);
}

27
frontend/src/TakerApp.tsx

@ -22,7 +22,7 @@ import { CfdTable } from "./components/cfdtables/CfdTable";
import CurrencyInputField from "./components/CurrencyInputField";
import CurrentPrice from "./components/CurrentPrice";
import useLatestEvent from "./components/Hooks";
import { Cfd, Order, PriceInfo, WalletInfo } from "./components/Types";
import { Cfd, intoCfd, intoOrder, Order, PriceInfo, StateGroupKey, WalletInfo } from "./components/Types";
import Wallet from "./components/Wallet";
interface CfdOrderRequestPayload {
@ -61,9 +61,9 @@ async function getMargin(payload: MarginRequestPayload): Promise<MarginResponse>
export default function App() {
let source = useEventSource({ source: "/api/feed" });
const cfdsOrUndefined = useLatestEvent<Cfd[]>(source, "cfds");
const cfdsOrUndefined = useLatestEvent<Cfd[]>(source, "cfds", intoCfd);
let cfds = cfdsOrUndefined ? cfdsOrUndefined! : [];
const order = useLatestEvent<Order>(source, "order");
const order = useLatestEvent<Order>(source, "order", intoOrder);
const walletInfo = useLatestEvent<WalletInfo>(source, "wallet");
const priceInfo = useLatestEvent<PriceInfo>(source, "quote");
@ -111,14 +111,9 @@ 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)
);
const opening = cfds.filter((value) => value.state.getGroup() === StateGroupKey.OPENING);
const open = cfds.filter((value) => value.state.getGroup() === StateGroupKey.OPEN);
const closed = cfds.filter((value) => value.state.getGroup() === StateGroupKey.CLOSED);
return (
<Container maxWidth="120ch" marginTop="1rem">
@ -193,20 +188,20 @@ export default function App() {
</HStack>
<Tabs marginTop={5}>
<TabList>
<Tab>Running [{running.length}]</Tab>
<Tab>Open [{open.length}]</Tab>
<Tab>Opening [{opening.length}]</Tab>
<Tab>Closed [{closed.length}]</Tab>
<Tab>Unsorted [{unsorted.length}] (should be empty)</Tab>
</TabList>
<TabPanels>
<TabPanel>
<CfdTable data={running} />
<CfdTable data={open} />
</TabPanel>
<TabPanel>
<CfdTable data={closed} />
<CfdTable data={opening} />
</TabPanel>
<TabPanel>
<CfdTable data={unsorted} />
<CfdTable data={closed} />
</TabPanel>
</TabPanels>
</Tabs>

13
frontend/src/components/Hooks.tsx

@ -1,7 +1,11 @@
import React, { useState } from "react";
import { useEventSource, useEventSourceListener } from "react-sse-hooks";
import { useEventSourceListener } from "react-sse-hooks";
export default function useLatestEvent<T>(source: EventSource, event_name: string): T | null {
export default function useLatestEvent<T>(
source: EventSource,
event_name: string,
mapping: (key: string, value: any) => any = (key, value) => value,
): T | null {
const [state, setState] = useState<T | null>(null);
useEventSourceListener<T | null>(
@ -10,7 +14,10 @@ export default function useLatestEvent<T>(source: EventSource, event_name: strin
startOnInit: true,
event: {
name: event_name,
listener: ({ data }) => setState(data),
listener: ({ event }) => {
// @ts-ignore - yes, there is a data field on event
setState(JSON.parse(event.data, mapping));
},
},
},
[source],

164
frontend/src/components/Types.tsx

@ -1,7 +1,7 @@
export interface Order {
id: string;
trading_pair: string;
position: string;
position: Position;
price: number;
min_quantity: number;
max_quantity: number;
@ -11,13 +11,31 @@ export interface Order {
term_in_secs: number;
}
export class Position {
constructor(public key: PositionKey) {}
public getColorScheme(): string {
switch (this.key) {
case PositionKey.BUY:
return "green";
case PositionKey.SELL:
return "red";
}
}
}
enum PositionKey {
BUY = "Buy",
SELL = "Sell",
}
export interface Cfd {
order_id: string;
initial_price: number;
leverage: number;
trading_pair: string;
position: string;
position: Position;
liquidation_price: number;
quantity_usd: number;
@ -27,10 +45,130 @@ export interface Cfd {
profit_btc: number;
profit_usd: number;
state: string;
state: State;
actions: Action[];
state_transition_timestamp: number;
}
export class State {
constructor(public key: StateKey) {}
public getLabel(): string {
switch (this.key) {
case StateKey.INCOMING_ORDER_REQUEST:
return "Take Requested";
case StateKey.OUTGOING_ORDER_REQUEST:
return "Take Requested";
case StateKey.ACCEPTED:
return "Accepted";
case StateKey.REJECTED:
return "Rejected";
case StateKey.CONTRACT_SETUP:
return "Contract Setup";
case StateKey.PENDING_OPEN:
return "Pending Open";
case StateKey.OPEN:
return "Open";
case StateKey.OPEN_COMMITTED:
return "Open (commit-tx published)";
case StateKey.MUST_REFUND:
return "Refunding";
case StateKey.REFUNDED:
return "Refunded";
case StateKey.SETUP_FAILED:
return "Setup Failed";
}
}
public getColorScheme(): string {
const default_color = "gray";
const green = "green";
const red = "red";
const orange = "orange";
switch (this.key) {
case StateKey.OUTGOING_ORDER_REQUEST:
return default_color;
case StateKey.INCOMING_ORDER_REQUEST:
return default_color;
case StateKey.ACCEPTED:
return green;
case StateKey.REJECTED:
return red;
case StateKey.CONTRACT_SETUP:
return default_color;
case StateKey.PENDING_OPEN:
return default_color;
case StateKey.OPEN:
return green;
case StateKey.OPEN_COMMITTED:
return orange;
case StateKey.MUST_REFUND:
return orange;
case StateKey.REFUNDED:
return default_color;
case StateKey.SETUP_FAILED:
return default_color;
}
}
public getGroup(): StateGroupKey {
switch (this.key) {
case StateKey.OUTGOING_ORDER_REQUEST:
return StateGroupKey.OPENING;
case StateKey.INCOMING_ORDER_REQUEST:
return StateGroupKey.ACCEPT_OR_REJECT;
case StateKey.ACCEPTED:
return StateGroupKey.OPENING;
case StateKey.REJECTED:
return StateGroupKey.CLOSED;
case StateKey.CONTRACT_SETUP:
return StateGroupKey.OPENING;
case StateKey.PENDING_OPEN:
return StateGroupKey.OPEN;
case StateKey.OPEN:
return StateGroupKey.OPEN;
case StateKey.OPEN_COMMITTED:
return StateGroupKey.OPEN;
case StateKey.MUST_REFUND:
return StateGroupKey.OPEN;
case StateKey.REFUNDED:
return StateGroupKey.CLOSED;
case StateKey.SETUP_FAILED:
return StateGroupKey.CLOSED;
}
}
}
export enum Action {
ACCEPT = "accept",
REJECT = "reject",
}
const enum StateKey {
OUTGOING_ORDER_REQUEST = "OutgoingOrderRequest",
INCOMING_ORDER_REQUEST = "IncomingOrderRequest",
ACCEPTED = "Accepted",
REJECTED = "Rejected",
CONTRACT_SETUP = "ContractSetup",
PENDING_OPEN = "PendingOpen",
OPEN = "Open",
OPEN_COMMITTED = "OpenCommitted",
MUST_REFUND = "MustRefund",
REFUNDED = "Refunded",
SETUP_FAILED = "SetupFailed",
}
export enum StateGroupKey {
/// A CFD which is still being set up (not on chain yet)
OPENING = "Opening",
ACCEPT_OR_REJECT = "Accept / Reject",
/// A CFD that is an ongoing open position (on chain)
OPEN = "Open",
/// A CFD that has been successfully or not-successfully terminated
CLOSED = "Closed",
}
export interface WalletInfo {
balance: number;
address: string;
@ -46,3 +184,23 @@ export interface PriceInfo {
export function unixTimestampToDate(unixTimestamp: number): Date {
return new Date(unixTimestamp * 1000);
}
export function intoCfd(key: string, value: any): any {
switch (key) {
case "position":
return new Position(value);
case "state":
return new State(value);
default:
return value;
}
}
export function intoOrder(key: string, value: any): any {
switch (key) {
case "position":
return new Position(value);
default:
return value;
}
}

104
frontend/src/components/cfdtables/CfdTable.tsx

@ -1,8 +1,29 @@
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 {
CheckIcon,
ChevronRightIcon,
ChevronUpIcon,
CloseIcon,
TriangleDownIcon,
TriangleUpIcon,
} from "@chakra-ui/icons";
import {
Badge,
Box,
chakra,
HStack,
IconButton,
Table as CUITable,
Tbody,
Td,
Th,
Thead,
Tr,
useToast,
} from "@chakra-ui/react";
import React from "react";
import { useAsync } from "react-async";
import { Column, Row, useExpanded, useSortBy, useTable } from "react-table";
import { Cfd } from "../Types";
import { Action, Cfd } from "../Types";
interface CfdTableProps {
data: Cfd[];
@ -11,6 +32,26 @@ interface CfdTableProps {
export function CfdTable(
{ data }: CfdTableProps,
) {
const toast = useToast();
let { run: postAction, isLoading: isActioning } = useAsync({
deferFn: async ([orderId, action]: any[]) => {
try {
await doPostAction(orderId, action);
} catch (e) {
const description = typeof e === "string" ? e : JSON.stringify(e);
toast({
title: "Error",
description,
status: "error",
duration: 9000,
isClosable: true,
});
}
},
});
const tableData = React.useMemo(
() => data,
[data],
@ -48,12 +89,8 @@ export function CfdTable(
{
Header: "Position",
accessor: ({ position }) => {
let colorScheme = "green";
if (position.toLocaleLowerCase() === "buy") {
colorScheme = "purple";
}
return (
<Badge colorScheme={colorScheme}>{position}</Badge>
<Badge colorScheme={position.getColorScheme()}>{position.key}</Badge>
);
},
isNumeric: true,
@ -104,18 +141,28 @@ export function CfdTable(
{
Header: "State",
accessor: ({ state }) => {
let colorScheme = "gray";
if (state.toLowerCase() === "rejected") {
colorScheme = "red";
}
if (state.toLowerCase() === "contract setup") {
colorScheme = "green";
}
return (
<Badge colorScheme={colorScheme}>{state}</Badge>
<Badge colorScheme={state.getColorScheme()}>{state.getLabel()}</Badge>
);
},
},
{
Header: "Action",
accessor: ({ actions, order_id }) => {
const actionIcons = actions.map((action) => {
return (<IconButton
key={action}
colorScheme={colorSchemaForAction(action)}
aria-label={action}
icon={iconForAction(action)}
onClick={async () => postAction(order_id, action)}
isLoading={isActioning}
/>);
});
return <HStack>{actionIcons}</HStack>;
},
},
],
[],
);
@ -133,6 +180,24 @@ export function CfdTable(
);
}
function iconForAction(action: Action): any {
switch (action) {
case Action.ACCEPT:
return <CheckIcon />;
case Action.REJECT:
return <CloseIcon />;
}
}
function colorSchemaForAction(action: Action): string {
switch (action) {
case Action.ACCEPT:
return "green";
case Action.REJECT:
return "red";
}
}
function renderRowSubComponent(row: Row<Cfd>) {
// TODO: I would show additional information here such as txids, timestamps, actions
let cells = row.allCells
@ -270,3 +335,10 @@ export function Table({ columns, tableData, hiddenColumns, renderDetails }: Tabl
</>
);
}
async function doPostAction(id: string, action: string) {
let res = await fetch(
`/api/cfd/${id}/${action}`,
{ method: "POST", credentials: "include" },
);
}

236
frontend/src/components/cfdtables/CfdTableMaker.tsx

@ -1,236 +0,0 @@
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<Column<Cfd>> = React.useMemo(
() => [
{
id: "expander",
Header: () => null,
Cell: ({ row }: any) => (
<span {...row.getToggleRowExpandedProps()}>
{row.isExpanded
? <IconButton
aria-label="Reduce"
icon={<ChevronUpIcon />}
onClick={() => {
row.toggleRowExpanded();
}}
/>
: <IconButton
aria-label="Expand"
icon={<ChevronRightIcon />}
onClick={() => {
row.toggleRowExpanded();
}}
/>}
</span>
),
},
{
Header: "OrderId",
accessor: "order_id",
},
{
Header: "Position",
accessor: ({ position }) => {
let colorScheme = "green";
if (position.toLocaleLowerCase() === "buy") {
colorScheme = "purple";
}
return (
<Badge colorScheme={colorScheme}>{position}</Badge>
);
},
isNumeric: true,
},
{
Header: "Quantity",
accessor: ({ quantity_usd }) => {
return (<Dollars amount={quantity_usd} />);
},
isNumeric: true,
},
{
Header: "Leverage",
accessor: "leverage",
isNumeric: true,
},
{
Header: "Margin",
accessor: "margin",
isNumeric: true,
},
{
Header: "Initial Price",
accessor: ({ initial_price }) => {
return (<Dollars amount={initial_price} />);
},
isNumeric: true,
},
{
Header: "Liquidation Price",
isNumeric: true,
accessor: ({ liquidation_price }) => {
return (<Dollars amount={liquidation_price} />);
},
},
{
Header: "Unrealized P/L",
accessor: ({ profit_usd }) => {
return (<Dollars amount={profit_usd} />);
},
isNumeric: true,
},
{
Header: "Timestamp",
accessor: "state_transition_timestamp",
},
{
Header: "Action",
accessor: ({ state, order_id }) => {
if (state.toLowerCase() === "requested") {
return (<HStack>
<IconButton
colorScheme="green"
aria-label="Accept"
icon={<CheckIcon />}
onClick={async () => acceptOrder(order_id)}
isLoading={isAccepting}
/>
<IconButton
colorScheme="red"
aria-label="Reject"
icon={<CloseIcon />}
onClick={async () => rejectOrder(order_id)}
isLoading={isRejecting}
/>
</HStack>);
}
let colorScheme = "gray";
if (state.toLowerCase() === "rejected") {
colorScheme = "red";
}
if (state.toLowerCase() === "contract setup") {
colorScheme = "green";
}
return (
<Badge colorScheme={colorScheme}>{state}</Badge>
);
},
},
],
[],
);
// 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 (
<Table
tableData={tableData}
columns={columns}
hiddenColumns={hiddenColumns}
renderDetails={renderRowSubComponent}
/>
);
}
function renderRowSubComponent(row: Row<Cfd>) {
// 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...
<HStack>
{cells.map(cell => (
<Box key={cell.column.id}>
{cell.column.id} = {cell.render("Cell")}
</Box>
))}
</HStack>
</>
);
}
interface DollarsProps {
amount: number;
}
function Dollars({ amount }: DollarsProps) {
const price = Math.floor(amount * 100.0) / 100.0;
return (
<>
$ {price}
</>
);
}
Loading…
Cancel
Save