From 3bb9fa0d717d2d672b5414205fab521c9495b8c0 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Wed, 31 Jan 2024 13:20:25 +0100 Subject: [PATCH 1/2] chore: extract components into separate files --- .../lib/trade/order_and_position_table.dart | 280 +----------------- webapp/frontend/lib/trade/position_table.dart | 130 ++++++++ .../lib/trade/trade_confirmation_dialog.dart | 153 ++++++++++ 3 files changed, 286 insertions(+), 277 deletions(-) create mode 100644 webapp/frontend/lib/trade/position_table.dart create mode 100644 webapp/frontend/lib/trade/trade_confirmation_dialog.dart diff --git a/webapp/frontend/lib/trade/order_and_position_table.dart b/webapp/frontend/lib/trade/order_and_position_table.dart index 6e97c2d1b..6291af04b 100644 --- a/webapp/frontend/lib/trade/order_and_position_table.dart +++ b/webapp/frontend/lib/trade/order_and_position_table.dart @@ -1,18 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/color.dart'; -import 'package:get_10101/common/contract_symbol_icon.dart'; -import 'package:get_10101/common/direction.dart'; -import 'package:get_10101/common/model.dart'; -import 'package:get_10101/common/snack_bar.dart'; -import 'package:get_10101/common/theme.dart'; -import 'package:get_10101/common/value_data_row.dart'; -import 'package:get_10101/trade/new_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:intl/intl.dart'; -import 'package:provider/provider.dart'; +import 'package:get_10101/trade/position_table.dart'; class OrderAndPositionTable extends StatefulWidget { const OrderAndPositionTable({super.key}); @@ -38,10 +26,10 @@ class OrderAndPositionTableState extends State isScrollable: false, tabs: const [ Tab( - text: 'Open', + text: 'Open Position', ), Tab( - text: 'Pending', + text: 'Order History', ), ], ), @@ -57,265 +45,3 @@ class OrderAndPositionTableState extends State ); } } - -class OpenPositionTable extends StatelessWidget { - const OpenPositionTable({super.key}); - - @override - Widget build(BuildContext context) { - final positionChangeNotifier = context.watch(); - final positions = positionChangeNotifier.getPositions(); - final quoteChangeNotifier = context.watch(); - final quote = quoteChangeNotifier.getBestQuote(); - - if (positions == null) { - return const Center(child: CircularProgressIndicator()); - } - - if (positions.isEmpty) { - return const Center(child: Text('No data available')); - } else { - return buildTable(positions, quote, context); - } - } - - Widget buildTable(List positions, BestQuote? bestQuote, BuildContext context) { - 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(150.0), FractionColumnWidth(0.1)), - 4: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), - 5: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), - 6: MinColumnWidth(FixedColumnWidth(200.0), FractionColumnWidth(0.25)), - 7: FixedColumnWidth(100), - }, - 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('Quantity'), - buildHeaderCell('Entry Price'), - buildHeaderCell('Liquidation Price'), - buildHeaderCell('Margin'), - buildHeaderCell('Leverage'), - buildHeaderCell('Unrealized PnL'), - buildHeaderCell('Expiry'), - buildHeaderCell('Action'), - ], - ), - for (var position in positions) - TableRow( - children: [ - buildTableCell(Text(position.direction == "Short" - ? "-${position.quantity}" - : "+${position.quantity}")), - buildTableCell(Text(position.averageEntryPrice.toString())), - buildTableCell(Text(position.liquidationPrice.toString())), - buildTableCell(Text(position.collateral.toString())), - buildTableCell(Text(position.leverage.formatted())), - buildTableCell(Text(position.pnlSats.toString())), - buildTableCell( - Text("${DateFormat('dd-MM-yyyy – HH:mm').format(position.expiry)} UTC")), - buildTableCell(Center( - child: SizedBox( - width: 100, - child: ElevatedButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return TradeConfirmationDialog( - direction: Direction.fromString(position.direction), - onConfirmation: () {}, - bestQuote: bestQuote, - pnl: position.pnlSats, - fee: position.closingFee, - payout: position.closingFee != null - ? Amount(position.collateral.sats + - (position.pnlSats?.sats ?? 0) - - (position.closingFee?.sats ?? 0)) - : null, - leverage: position.leverage, - quantity: position.quantity, - ); - }); - }, - child: const Text("Close", style: TextStyle(fontSize: 16))), - ), - )), - ], - ), - ], - ); - } - - 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))); -} - -class TradeConfirmationDialog extends StatelessWidget { - final Direction direction; - final Function() onConfirmation; - final BestQuote? bestQuote; - final Amount? pnl; - final Amount? fee; - final Amount? payout; - final Leverage leverage; - final Usd quantity; - - const TradeConfirmationDialog( - {super.key, - required this.direction, - required this.onConfirmation, - required this.bestQuote, - required this.pnl, - required this.fee, - required this.payout, - required this.leverage, - required this.quantity}); - - @override - Widget build(BuildContext context) { - final messenger = ScaffoldMessenger.of(context); - TenTenOneTheme tradeTheme = Theme.of(context).extension()!; - - TextStyle dataRowStyle = const TextStyle(fontSize: 14); - - Price? price = bestQuote?.bid; - if (direction == Direction.short) { - price = bestQuote?.ask; - } - - Color color = direction == Direction.long ? tradeTheme.buy : tradeTheme.sell; - - return Dialog( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - width: 340, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - const ContractSymbolIcon(), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text("Market ${direction.nameU}", - style: - TextStyle(fontWeight: FontWeight.bold, fontSize: 17, color: color)), - ), - Center( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - children: [ - Wrap( - runSpacing: 10, - children: [ - ValueDataRow( - type: ValueType.fiat, - value: price?.asDouble ?? 0.0, - label: 'Latest Market Price'), - ValueDataRow( - type: ValueType.amount, - value: pnl, - label: 'Unrealized P/L', - valueTextStyle: dataRowStyle.apply( - color: pnl != null - ? pnl!.sats.isNegative - ? tradeTheme.loss - : tradeTheme.profit - : tradeTheme.disabled)), - ValueDataRow( - type: ValueType.amount, - value: fee, - label: "Fee estimate", - ), - ValueDataRow( - type: ValueType.amount, - value: payout, - label: "Payout estimate", - valueTextStyle: TextStyle( - fontSize: dataRowStyle.fontSize, - fontWeight: FontWeight.bold)), - ], - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 20.0), - child: RichText( - textAlign: TextAlign.justify, - text: TextSpan( - text: - 'By confirming, a closing market order will be created. Once the order is matched your position will be closed.', - style: DefaultTextStyle.of(context).style)), - ), - Padding( - padding: const EdgeInsets.only(top: 20.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: () { - Navigator.pop(context); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey, fixedSize: const Size(100, 20)), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () { - NewOrderService.postNewOrder( - leverage, quantity, direction == Direction.long.opposite()) - .then((orderId) { - showSnackBar( - messenger, "Closing order created. Order id: $orderId."); - Navigator.pop(context); - }); - }, - style: ElevatedButton.styleFrom(fixedSize: const Size(100, 20)), - child: const Text('Accept'), - ), - ], - ), - ), - ], - )) - ], - ), - ), - ), - ); - } -} diff --git a/webapp/frontend/lib/trade/position_table.dart b/webapp/frontend/lib/trade/position_table.dart new file mode 100644 index 000000000..9c20395e0 --- /dev/null +++ b/webapp/frontend/lib/trade/position_table.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.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/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 OpenPositionTable extends StatelessWidget { + const OpenPositionTable({super.key}); + + @override + Widget build(BuildContext context) { + final positionChangeNotifier = context.watch(); + final positions = positionChangeNotifier.getPositions(); + final quoteChangeNotifier = context.watch(); + final quote = quoteChangeNotifier.getBestQuote(); + + if (positions == null) { + return const Center(child: CircularProgressIndicator()); + } + + if (positions.isEmpty) { + return const Center(child: Text('No data available')); + } else { + return buildTable(positions, quote, context); + } + } + + Widget buildTable(List positions, BestQuote? bestQuote, BuildContext context) { + 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(150.0), FractionColumnWidth(0.1)), + 4: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 5: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 6: MinColumnWidth(FixedColumnWidth(200.0), FractionColumnWidth(0.25)), + 7: FixedColumnWidth(100), + }, + 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('Quantity'), + buildHeaderCell('Entry Price'), + buildHeaderCell('Liquidation Price'), + buildHeaderCell('Margin'), + buildHeaderCell('Leverage'), + buildHeaderCell('Unrealized PnL'), + buildHeaderCell('Expiry'), + buildHeaderCell('Action'), + ], + ), + for (var position in positions) + TableRow( + children: [ + buildTableCell(Text(position.direction == "Short" + ? "-${position.quantity}" + : "+${position.quantity}")), + buildTableCell(Text(position.averageEntryPrice.toString())), + buildTableCell(Text(position.liquidationPrice.toString())), + buildTableCell(Text(position.collateral.toString())), + buildTableCell(Text(position.leverage.formatted())), + buildTableCell(Text(position.pnlSats.toString())), + buildTableCell( + Text("${DateFormat('dd-MM-yyyy – HH:mm').format(position.expiry)} UTC")), + buildTableCell(Center( + child: SizedBox( + width: 100, + child: ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return TradeConfirmationDialog( + direction: Direction.fromString(position.direction), + onConfirmation: () {}, + bestQuote: bestQuote, + pnl: position.pnlSats, + fee: position.closingFee, + payout: position.closingFee != null + ? Amount(position.collateral.sats + + (position.pnlSats?.sats ?? 0) - + (position.closingFee?.sats ?? 0)) + : null, + leverage: position.leverage, + quantity: position.quantity, + ); + }); + }, + child: const Text("Close", style: TextStyle(fontSize: 16))), + ), + )), + ], + ), + ], + ); + } + + 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/trade_confirmation_dialog.dart b/webapp/frontend/lib/trade/trade_confirmation_dialog.dart new file mode 100644 index 000000000..baa0959d4 --- /dev/null +++ b/webapp/frontend/lib/trade/trade_confirmation_dialog.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/contract_symbol_icon.dart'; +import 'package:get_10101/common/direction.dart'; +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/common/theme.dart'; +import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/trade/new_order_service.dart'; +import 'package:get_10101/trade/quote_service.dart'; + +class TradeConfirmationDialog extends StatelessWidget { + final Direction direction; + final Function() onConfirmation; + final BestQuote? bestQuote; + final Amount? pnl; + final Amount? fee; + final Amount? payout; + final Leverage leverage; + final Usd quantity; + + const TradeConfirmationDialog( + {super.key, + required this.direction, + required this.onConfirmation, + required this.bestQuote, + required this.pnl, + required this.fee, + required this.payout, + required this.leverage, + required this.quantity}); + + @override + Widget build(BuildContext context) { + final messenger = ScaffoldMessenger.of(context); + TenTenOneTheme tradeTheme = Theme.of(context).extension()!; + + TextStyle dataRowStyle = const TextStyle(fontSize: 14); + + Price? price = bestQuote?.bid; + if (direction == Direction.short) { + price = bestQuote?.ask; + } + + Color color = direction == Direction.long ? tradeTheme.buy : tradeTheme.sell; + + return Dialog( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + const ContractSymbolIcon(), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text("Market ${direction.nameU}", + style: + TextStyle(fontWeight: FontWeight.bold, fontSize: 17, color: color)), + ), + Center( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + children: [ + Wrap( + runSpacing: 10, + children: [ + ValueDataRow( + type: ValueType.fiat, + value: price?.asDouble ?? 0.0, + label: 'Latest Market Price'), + ValueDataRow( + type: ValueType.amount, + value: pnl, + label: 'Unrealized P/L', + valueTextStyle: dataRowStyle.apply( + color: pnl != null + ? pnl!.sats.isNegative + ? tradeTheme.loss + : tradeTheme.profit + : tradeTheme.disabled)), + ValueDataRow( + type: ValueType.amount, + value: fee, + label: "Fee estimate", + ), + ValueDataRow( + type: ValueType.amount, + value: payout, + label: "Payout estimate", + valueTextStyle: TextStyle( + fontSize: dataRowStyle.fontSize, + fontWeight: FontWeight.bold)), + ], + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + text: + 'By confirming, a closing market order will be created. Once the order is matched your position will be closed.', + style: DefaultTextStyle.of(context).style)), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, fixedSize: const Size(100, 20)), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + NewOrderService.postNewOrder( + leverage, quantity, direction == Direction.long.opposite()) + .then((orderId) { + showSnackBar( + messenger, "Closing order created. Order id: $orderId."); + Navigator.pop(context); + }); + }, + style: ElevatedButton.styleFrom(fixedSize: const Size(100, 20)), + child: const Text('Accept'), + ), + ], + ), + ), + ], + )) + ], + ), + ), + ), + ); + } +} From d2e1b3b7a82796a8b108622870f6a7117bee7d13 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Wed, 31 Jan 2024 15:26:39 +0100 Subject: [PATCH 2/2] feat(): show order history --- CHANGELOG.md | 2 + mobile/native/src/trade/order/mod.rs | 7 +- webapp/frontend/lib/common/order_type.dart | 25 ++++ webapp/frontend/lib/main.dart | 3 + .../lib/trade/order_and_position_table.dart | 3 +- .../lib/trade/order_change_notifier.dart | 39 ++++++ .../lib/trade/order_history_table.dart | 128 ++++++++++++++++++ webapp/frontend/lib/trade/order_service.dart | 112 +++++++++++++++ webapp/src/api.rs | 127 ++++++++++++++++- 9 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 webapp/frontend/lib/common/order_type.dart create mode 100644 webapp/frontend/lib/trade/order_change_notifier.dart create mode 100644 webapp/frontend/lib/trade/order_history_table.dart create mode 100644 webapp/frontend/lib/trade/order_service.dart 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,