diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdd997df..2a3a31f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Feat(webapp): Show order history + ## [1.8.4] - 2024-01-31 - Chore: Add funding txid to list dlc channels api diff --git a/mobile/native/src/trade/order/mod.rs b/mobile/native/src/trade/order/mod.rs index 614baf836..642248fe4 100644 --- a/mobile/native/src/trade/order/mod.rs +++ b/mobile/native/src/trade/order/mod.rs @@ -1,6 +1,7 @@ use crate::calculations::calculate_margin; use crate::ln_dlc; use rust_decimal::Decimal; +use serde::Serialize; use time::OffsetDateTime; use trade::ContractSymbol; use trade::Direction; @@ -13,14 +14,14 @@ mod orderbook_client; // When naming this the same as `api_model::order::OrderType` the generated code somehow uses // `trade::OrderType` and contains errors, hence different name is used. // This is likely a bug in frb. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub enum OrderType { Market, Limit { price: f32 }, } /// Internal type so we still have Copy on order -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub enum FailureReason { /// An error occurred when setting the Order to filling in our DB FailedToSetToFilling, @@ -41,7 +42,7 @@ pub enum FailureReason { Unknown, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] pub enum InvalidSubchannelOffer { /// Received offer was outdated Outdated, diff --git a/webapp/frontend/lib/common/order_type.dart b/webapp/frontend/lib/common/order_type.dart new file mode 100644 index 000000000..1c561df10 --- /dev/null +++ b/webapp/frontend/lib/common/order_type.dart @@ -0,0 +1,25 @@ +enum OrderType { + market, + limit; + + String get asString { + switch (this) { + case OrderType.market: + return "Market"; + case OrderType.limit: + return "Limit"; + } + } + + // Factory method to convert a String to OrderType + static OrderType fromString(String value) { + switch (value.toLowerCase()) { + case 'market': + return OrderType.market; + case 'limit': + return OrderType.limit; + default: + throw ArgumentError('Invalid OrderType: $value'); + } + } +} diff --git a/webapp/frontend/lib/main.dart b/webapp/frontend/lib/main.dart index 6d15b678c..1a9e74dcb 100644 --- a/webapp/frontend/lib/main.dart +++ b/webapp/frontend/lib/main.dart @@ -3,6 +3,8 @@ import 'package:get_10101/auth/auth_service.dart'; import 'package:get_10101/common/version_service.dart'; import 'package:get_10101/logger/logger.dart'; import 'package:get_10101/routes.dart'; +import 'package:get_10101/trade/order_change_notifier.dart'; +import 'package:get_10101/trade/order_service.dart'; import 'package:get_10101/trade/position_change_notifier.dart'; import 'package:get_10101/trade/position_service.dart'; import 'package:get_10101/trade/quote_change_notifier.dart'; @@ -25,6 +27,7 @@ void main() { ChangeNotifierProvider(create: (context) => WalletChangeNotifier(const WalletService())), ChangeNotifierProvider(create: (context) => QuoteChangeNotifier(const QuoteService())), ChangeNotifierProvider(create: (context) => PositionChangeNotifier(const PositionService())), + ChangeNotifierProvider(create: (context) => OrderChangeNotifier(const OrderService())), Provider(create: (context) => const SettingsService()), Provider(create: (context) => AuthService()) ]; diff --git a/webapp/frontend/lib/trade/order_and_position_table.dart b/webapp/frontend/lib/trade/order_and_position_table.dart index 6291af04b..aa837895a 100644 --- a/webapp/frontend/lib/trade/order_and_position_table.dart +++ b/webapp/frontend/lib/trade/order_and_position_table.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/color.dart'; +import 'package:get_10101/trade/order_history_table.dart'; import 'package:get_10101/trade/position_table.dart'; class OrderAndPositionTable extends StatefulWidget { @@ -38,7 +39,7 @@ class OrderAndPositionTableState extends State controller: _tabController, children: const [ OpenPositionTable(), - Text("Pending"), + OrderHistoryTable(), ], )) ], diff --git a/webapp/frontend/lib/trade/order_change_notifier.dart b/webapp/frontend/lib/trade/order_change_notifier.dart new file mode 100644 index 000000000..a432a81ff --- /dev/null +++ b/webapp/frontend/lib/trade/order_change_notifier.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/trade/order_service.dart'; +import 'package:get_10101/trade/position_service.dart'; + +class OrderChangeNotifier extends ChangeNotifier { + final OrderService service; + late Timer timer; + + List? _orders; + + OrderChangeNotifier(this.service) { + _refresh(); + Timer.periodic(const Duration(seconds: 2), (timer) async { + _refresh(); + }); + } + + void _refresh() async { + try { + final orders = await service.fetchOrders(); + _orders = orders; + + super.notifyListeners(); + } catch (error) { + logger.e(error); + } + } + + List? getOrders() => _orders; + + @override + void dispose() { + super.dispose(); + timer.cancel(); + } +} diff --git a/webapp/frontend/lib/trade/order_history_table.dart b/webapp/frontend/lib/trade/order_history_table.dart new file mode 100644 index 000000000..8cdda0f5b --- /dev/null +++ b/webapp/frontend/lib/trade/order_history_table.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/direction.dart'; +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/trade/order_change_notifier.dart'; +import 'package:get_10101/trade/order_service.dart'; +import 'package:get_10101/trade/position_change_notifier.dart'; +import 'package:get_10101/trade/position_service.dart'; +import 'package:get_10101/trade/quote_change_notifier.dart'; +import 'package:get_10101/trade/quote_service.dart'; +import 'package:get_10101/trade/trade_confirmation_dialog.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class OrderHistoryTable extends StatelessWidget { + const OrderHistoryTable({super.key}); + + @override + Widget build(BuildContext context) { + final orderChangeNotified = context.watch(); + final orders = orderChangeNotified.getOrders(); + + if (orders == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (orders.isEmpty) { + return const Center(child: Text('No data available')); + } else { + return buildTable(orders, context); + } + } + + Widget buildTable(List orders, BuildContext context) { + orders.sort((a, b) => b.creationTimestamp.compareTo(a.creationTimestamp)); + + return Table( + border: TableBorder.symmetric(inside: const BorderSide(width: 2, color: Colors.black)), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: const { + 0: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 1: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 2: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 3: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 4: MinColumnWidth(FixedColumnWidth(200.0), FractionColumnWidth(0.2)), + }, + children: [ + TableRow( + decoration: BoxDecoration( + color: tenTenOnePurple.shade300, + border: Border.all( + width: 1, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), topRight: Radius.circular(10)), + ), + children: [ + buildHeaderCell('State'), + buildHeaderCell('Price'), + buildHeaderCell('Quantity'), + buildHeaderCell('Leverage'), + buildHeaderCell('Timestamp'), + ], + ), + for (var order in orders) + TableRow( + children: [ + buildTableCell(Tooltip(message: order.state.asString, child: stateToIcon(order))), + // buildTableCell(Text(order.id)), + buildTableCell(Text(order.price != null ? order.price.toString() : "NaN")), + buildTableCell( + Text(order.direction == "Short" ? "-${order.quantity}" : "+${order.quantity}")), + buildTableCell(Text("${order.leverage.formatted()}x")), + buildTableCell( + Text("${DateFormat('dd-MM-yyyy – HH:mm').format(order.creationTimestamp)} UTC")), + ], + ), + ], + ); + } + + Widget stateToIcon(Order order) { + const double size = 16.0; + var icon = switch (order.state) { + OrderState.initial => + const SizedBox(width: size, height: size, child: CircularProgressIndicator()), + OrderState.rejected => const Icon( + FontAwesomeIcons.circleExclamation, + size: size, + ), + OrderState.open => + const SizedBox(width: size, height: size, child: CircularProgressIndicator()), + OrderState.filling => + const SizedBox(width: size, height: size, child: CircularProgressIndicator()), + OrderState.failed => const Icon( + FontAwesomeIcons.circleExclamation, + color: Colors.red, + size: size, + ), + OrderState.filled => const Icon( + FontAwesomeIcons.check, + size: size, + ), + OrderState.unknown => const Icon( + FontAwesomeIcons.circleExclamation, + size: size, + ) + }; + return icon; + } + + TableCell buildHeaderCell(String text) { + return TableCell( + child: Container( + padding: const EdgeInsets.all(10), + alignment: Alignment.center, + child: Text(text, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)))); + } + + TableCell buildTableCell(Widget child) => TableCell( + child: Center( + child: Container( + padding: const EdgeInsets.all(10), alignment: Alignment.center, child: child))); +} diff --git a/webapp/frontend/lib/trade/order_service.dart b/webapp/frontend/lib/trade/order_service.dart new file mode 100644 index 000000000..64f477797 --- /dev/null +++ b/webapp/frontend/lib/trade/order_service.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'package:flutter/cupertino.dart'; +import 'package:get_10101/common/contract_symbol.dart'; +import 'package:get_10101/common/direction.dart'; +import 'package:get_10101/common/http_client.dart'; +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/common/order_type.dart'; + +class OrderService { + const OrderService(); + + Future> fetchOrders() async { + final response = await HttpClientManager.instance.get(Uri(path: '/api/orders')); + + if (response.statusCode == 200) { + final List jsonData = jsonDecode(response.body); + return jsonData.map((orderData) => Order.fromJson(orderData)).toList(); + } else { + throw FlutterError("Could not fetch orders"); + } + } +} + +class Order { + final String id; //: Uuid, + final Leverage leverage; //: f32, + final Usd quantity; //: f32, + final Usd? price; //: f32, + final ContractSymbol contractSymbol; //: ContractSymbol, + final Direction direction; //: Direction, + final OrderType orderType; //: OrderType, + // TODO: define a state + final OrderState state; //: OrderState, + final DateTime creationTimestamp; //: OffsetDateTime, + // TODO: define failure reason + final String? failureReason; //: Option, + + Order( + {required this.id, + required this.leverage, + required this.quantity, + required this.price, + required this.contractSymbol, + required this.direction, + required this.orderType, + required this.state, + required this.creationTimestamp, + required this.failureReason}); + + factory Order.fromJson(Map json) { + return Order( + id: json['id'] as String, + leverage: Leverage(json['leverage'] as double), + quantity: Usd(json['quantity'] as double), + price: json['price'] != null ? Usd(json['price'] as double) : null, + contractSymbol: ContractSymbol.btcusd, + direction: Direction.fromString(json['direction']), + creationTimestamp: DateTime.parse(json['creation_timestamp'] as String), + orderType: OrderType.fromString(json['order_type'] as String), + state: OrderState.fromString(json['state'] as String), //json['state'] as String, + failureReason: json['failure_reason'], // json['failure_reason'], + ); + } +} + +enum OrderState { + initial, + rejected, + open, + filling, + failed, + filled, + unknown; + + String get asString { + switch (this) { + case OrderState.initial: + return "Initial"; + case OrderState.rejected: + return "Rejected"; + case OrderState.open: + return "Open"; + case OrderState.filling: + return "Filling"; + case OrderState.failed: + return "Failed"; + case OrderState.filled: + return "Filled"; + case OrderState.unknown: + return "Unknown"; + } + } + + static OrderState fromString(String value) { + switch (value.toLowerCase()) { + case 'initial': + return OrderState.initial; + case 'rejected': + return OrderState.rejected; + case 'open': + return OrderState.open; + case 'filling': + return OrderState.filling; + case 'filled': + return OrderState.filled; + case 'failed': + return OrderState.failed; + default: + throw ArgumentError('Invalid OrderState: $json'); + } + } +} diff --git a/webapp/src/api.rs b/webapp/src/api.rs index e91903ef0..c55fec066 100644 --- a/webapp/src/api.rs +++ b/webapp/src/api.rs @@ -20,9 +20,9 @@ use native::api::SendPayment; use native::api::WalletHistoryItemType; use native::calculations::calculate_pnl; use native::ln_dlc; -use native::trade::order::OrderState; +use native::trade::order::FailureReason; +use native::trade::order::InvalidSubchannelOffer; use native::trade::order::OrderType; -use native::trade::position; use native::trade::position::PositionState; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; @@ -39,7 +39,7 @@ pub fn router(subscribers: Arc) -> Router { .route("/api/newaddress", get(get_unused_address)) .route("/api/sendpayment", post(send_payment)) .route("/api/history", get(get_onchain_payment_history)) - .route("/api/orders", post(post_new_order)) + .route("/api/orders", get(get_orders).post(post_new_order)) .route("/api/positions", get(get_positions)) .route("/api/quotes/:contract_symbol", get(get_best_quote)) .route("/api/node", get(get_node_id)) @@ -201,7 +201,7 @@ impl TryFrom for native::trade::order::Order { direction: value.direction, // We only support market orders for now order_type: OrderType::Market, - state: OrderState::Initial, + state: native::trade::order::OrderState::Initial, creation_timestamp: OffsetDateTime::now_utc(), // We do not support setting order expiry from the frontend for now order_expiry_timestamp: OffsetDateTime::now_utc() + time::Duration::minutes(1), @@ -301,7 +301,7 @@ pub async fn get_positions( ) -> Result>, AppError> { let orderbook_info = subscribers.orderbook_info(); - let positions = position::handler::get_positions()? + let positions = native::trade::position::handler::get_positions()? .into_iter() .map(|position| { let quotes = orderbook_info @@ -315,6 +315,123 @@ pub async fn get_positions( Ok(Json(positions)) } +#[derive(Serialize, Debug)] +pub struct Order { + pub id: Uuid, + pub leverage: f32, + pub quantity: f32, + /// An order only has a price if it either was filled or if it was a limit order (which is not + /// implemented yet). + pub price: Option, + pub contract_symbol: ContractSymbol, + pub direction: Direction, + pub order_type: OrderType, + pub state: OrderState, + #[serde(with = "time::serde::rfc3339")] + pub creation_timestamp: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub order_expiry_timestamp: OffsetDateTime, + pub failure_reason: Option, +} + +#[derive(Serialize, Debug, Clone)] +pub enum OrderState { + /// Not submitted to orderbook yet + Initial, + + /// Rejected by the orderbook upon submission + Rejected, + + /// Successfully submit to orderbook + Open, + + /// The orderbook has matched the order and it is being filled + Filling, + + /// The order failed to be filled + Failed, + + /// Successfully set up trade + Filled, +} + +impl From for OrderState { + fn from(value: native::trade::order::OrderState) -> Self { + match value { + native::trade::order::OrderState::Initial => OrderState::Initial, + native::trade::order::OrderState::Rejected => OrderState::Rejected, + native::trade::order::OrderState::Open => OrderState::Open, + native::trade::order::OrderState::Filling { .. } => OrderState::Filling, + native::trade::order::OrderState::Failed { .. } => OrderState::Failed, + native::trade::order::OrderState::Filled { .. } => OrderState::Filled, + } + } +} +impl From<&native::trade::order::Order> for Order { + fn from(value: &native::trade::order::Order) -> Self { + let failure_reason = match &value.failure_reason { + None => None, + Some(reason) => { + let reason = match reason { + FailureReason::FailedToSetToFilling => "FailedToSetToFilling", + FailureReason::TradeRequest => "TradeRequestFailed", + FailureReason::TradeResponse(error) => error.as_str(), + FailureReason::CollabRevert => "CollabRevert", + FailureReason::OrderNotAcceptable => "OrderNotAcceptable", + FailureReason::TimedOut => "TimedOut", + FailureReason::InvalidDlcOffer(error) => match error { + InvalidSubchannelOffer::Outdated => "OfferOutdated", + InvalidSubchannelOffer::UndeterminedMaturityDate => { + "OfferUndeterminedMaturityDate" + } + InvalidSubchannelOffer::Unacceptable => "OfferUnacceptable", + }, + FailureReason::OrderRejected => "OrderRejected", + FailureReason::Unknown => "Unknown", + } + .to_string(); + Some(reason) + } + }; + + let mut price = None; + + if let OrderType::Limit { price: limit_price } = value.order_type { + price.replace(limit_price); + } + + // Note: we might overwrite a limit price here but this is not an issue because if a limit + // order has been filled the limit price will be filled price and vice versa + if let native::trade::order::OrderState::Filled { execution_price } = value.state { + price.replace(execution_price); + } + + Order { + id: value.id, + leverage: value.leverage, + quantity: value.quantity, + price, + contract_symbol: value.contract_symbol, + direction: value.direction, + order_type: value.order_type, + state: value.state.clone().into(), + creation_timestamp: value.creation_timestamp, + order_expiry_timestamp: value.order_expiry_timestamp, + failure_reason, + } + } +} + +pub async fn get_orders() -> Result>, AppError> { + let orders = native::trade::order::handler::get_orders_for_ui() + .await? + .iter() + .map(|order| order.into()) + .collect(); + + Ok(Json(orders)) +} + pub async fn get_best_quote( State(subscribers): State>, Path(contract_symbol): Path,