diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccd8f6a6..ea89a5894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Feat: update api to collaboratively revert a dlc-channel - Feat: Allow continuing from an offered dlc channel state (offered, settle offered and collab close offered) - Feat: add a new project `webapp`. Eventually this will have the same functionality as our app (and more) and can be run on a self-hosted server -- Chore: In Webapp API allow requests from any origin (CORS) -- Feat: Allow creating new orders through `webapp` +- Chore (webapp): Add API allow requests from any origin (CORS) +- Feat (webapp): Allow creating new orders through `webapp` +- Feat (webapp): Show open position in trade screen ## [1.7.4] - 2023-12-20 diff --git a/mobile/native/src/trade/position/mod.rs b/mobile/native/src/trade/position/mod.rs index b433e4d4b..eed6629ef 100644 --- a/mobile/native/src/trade/position/mod.rs +++ b/mobile/native/src/trade/position/mod.rs @@ -14,6 +14,7 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use rust_decimal::RoundingStrategy; +use serde::Serialize; use time::OffsetDateTime; use trade::ContractSymbol; use trade::Direction; @@ -21,7 +22,7 @@ use trade::Direction; pub mod api; pub mod handler; -#[derive(Debug, Clone, PartialEq, Copy)] +#[derive(Debug, Clone, PartialEq, Copy, Serialize)] pub enum PositionState { /// The position is open /// @@ -57,7 +58,7 @@ pub enum PositionState { Resizing, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct Position { pub leverage: f32, pub quantity: f32, @@ -67,8 +68,11 @@ pub struct Position { pub liquidation_price: f32, pub position_state: PositionState, pub collateral: u64, + #[serde(with = "time::serde::rfc3339")] pub expiry: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] pub created: OffsetDateTime, pub stable: bool, } diff --git a/webapp/frontend/lib/common/model.dart b/webapp/frontend/lib/common/model.dart index f9a757193..3e416717e 100644 --- a/webapp/frontend/lib/common/model.dart +++ b/webapp/frontend/lib/common/model.dart @@ -79,8 +79,8 @@ class Amount implements Formattable { class Usd implements Formattable { Decimal _usd = Decimal.zero; - Usd(int usd) { - _usd = Decimal.fromInt(usd); + Usd(double usd) { + _usd = Decimal.parse(usd.toString()); } int get usd => _usd.toBigInt().toInt(); @@ -170,15 +170,13 @@ class Price implements Formattable { } class Leverage implements Formattable { - int _leverage = 1; - - Leverage.one() : _leverage = 1; + double _leverage = 1; - int get toInt => _leverage; + Leverage.one() : _leverage = 1.0; - double get asDouble => _leverage as double; + double get asDouble => _leverage; - Leverage(int leverage) { + Leverage(double leverage) { _leverage = leverage; } diff --git a/webapp/frontend/lib/trade/oder_and_position_table.dart b/webapp/frontend/lib/trade/oder_and_position_table.dart new file mode 100644 index 000000000..5cde5bed8 --- /dev/null +++ b/webapp/frontend/lib/trade/oder_and_position_table.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/trade/open_position_service.dart'; +import 'package:intl/intl.dart'; + +class OrderAndPositionTable extends StatefulWidget { + const OrderAndPositionTable({super.key}); + + @override + OrderAndPositionTableState createState() => OrderAndPositionTableState(); +} + +class OrderAndPositionTableState extends State + with SingleTickerProviderStateMixin { + late final _tabController = TabController(length: 2, vsync: this); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TabBar( + unselectedLabelColor: Colors.black, + labelColor: tenTenOnePurple, + controller: _tabController, + isScrollable: false, + tabs: const [ + Tab( + text: 'Open', + ), + Tab( + text: 'Pending', + ), + ], + ), + Container( + constraints: const BoxConstraints( + // Adding constraints to avoid unbounded height, 400 is a random number to avoid pixel overflow + maxHeight: 200, + ), + child: TabBarView( + controller: _tabController, + children: const [ + SimpleTableWidget(), + Text("Pending"), + ], + ), + ), + ], + ); + } +} + +class SimpleTableWidget extends StatelessWidget { + const SimpleTableWidget({super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: OpenPositionsService.fetchOpenPositions(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + logger.i("received ${snapshot.error}"); + return const Center(child: Text('Error loading data')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No data available')); + } else { + return buildTable(snapshot.data!); + } + }, + ); + } + + Widget buildTable(List positions) { + return Container( + decoration: BoxDecoration( + border: Border.all( + width: 1, + ), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: 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(100.0), FractionColumnWidth(0.1)), + 5: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 6: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 7: 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('Symbol'), + buildHeaderCell('Quantity'), + buildHeaderCell('Entry Price'), + buildHeaderCell('Liquidation Price'), + buildHeaderCell('Margin'), + buildHeaderCell('Leverage'), + buildHeaderCell('Unrealized PnL'), + buildHeaderCell('Expiry'), + ], + ), + for (var position in positions) + TableRow( + children: [ + buildTableCell(position.contractSymbol), + buildTableCell(position.quantity.formatted()), + buildTableCell(position.averageEntryPrice.formatted()), + buildTableCell(position.liquidationPrice.formatted()), + buildTableCell(position.collateral.formatted()), + buildTableCell(position.leverage.formatted()), + // TODO: we need to get the latest quote to be able to calculate this + buildTableCell("0.0"), + buildTableCell("${DateFormat('dd-MM-yyyy – kk:mm').format(position.expiry)} CET"), + ], + ), + ], + ), + ); + } + + 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(String text) => TableCell( + child: Center( + child: Container( + padding: const EdgeInsets.all(10), alignment: Alignment.center, child: Text(text)))); +} diff --git a/webapp/frontend/lib/trade/open_position_service.dart b/webapp/frontend/lib/trade/open_position_service.dart new file mode 100644 index 000000000..babc6ac07 --- /dev/null +++ b/webapp/frontend/lib/trade/open_position_service.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:flutter/cupertino.dart'; +import 'package:get_10101/common/http_client.dart'; +import 'package:get_10101/common/model.dart'; + +class OpenPositionsService { + const OpenPositionsService(); + + static Future> fetchOpenPositions() async { + final response = await HttpClientManager.instance.get(Uri(path: '/api/positions')); + + if (response.statusCode == 200) { + final List jsonData = jsonDecode(response.body); + return jsonData.map((positionData) => Position.fromJson(positionData)).toList(); + } else { + throw FlutterError("Could not fetch positions"); + } + } +} + +class Position { + final Leverage leverage; + final Usd quantity; + final String contractSymbol; + final String direction; + final Usd averageEntryPrice; + final Usd liquidationPrice; + final String positionState; + final Amount collateral; + final DateTime expiry; + final DateTime updated; + final DateTime created; + + Position({ + required this.leverage, + required this.quantity, + required this.contractSymbol, + required this.direction, + required this.averageEntryPrice, + required this.liquidationPrice, + required this.positionState, + required this.collateral, + required this.expiry, + required this.updated, + required this.created, + }); + + factory Position.fromJson(Map json) { + return Position( + leverage: Leverage(json['leverage'] as double), + quantity: Usd(json['quantity'] as double), + contractSymbol: json['contract_symbol'] as String, + direction: json['direction'] as String, + averageEntryPrice: Usd(json['average_entry_price'] as double), + liquidationPrice: Usd(json['liquidation_price'] as double), + positionState: json['position_state'] as String, + collateral: Amount(json['collateral']), + expiry: DateTime.parse(json['expiry'] as String), + updated: DateTime.parse(json['updated'] as String), + created: DateTime.parse(json['created'] as String), + ); + } +} diff --git a/webapp/frontend/lib/trade/trade_screen.dart b/webapp/frontend/lib/trade/trade_screen.dart index 0d8ac6d64..f441d464b 100644 --- a/webapp/frontend/lib/trade/trade_screen.dart +++ b/webapp/frontend/lib/trade/trade_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/color.dart'; +import 'package:get_10101/trade/oder_and_position_table.dart'; import 'package:get_10101/trade/trade_screen_order_form.dart'; class TradeScreen extends StatefulWidget { @@ -21,7 +22,9 @@ class _TradeScreenState extends State with SingleTickerProviderStat @override Widget build(BuildContext context) { - return NewOrderWidget(tabController: _tabController); + return ListView( + children: [NewOrderWidget(tabController: _tabController), OrderAndPositionTable()], + ); } } @@ -35,13 +38,13 @@ class NewOrderWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - width: 300, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - TabBar( + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 300, + child: TabBar( unselectedLabelColor: Colors.black, labelColor: tenTenOnePurple, controller: _tabController, @@ -54,19 +57,21 @@ class NewOrderWidget extends StatelessWidget { ), ], ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - NewOrderForm(isLong: true), - NewOrderForm( - isLong: false, - ) - ], - ), + ), + SizedBox( + height: 400, + width: 300, + child: TabBarView( + controller: _tabController, + children: [ + NewOrderForm(isLong: true), + NewOrderForm( + isLong: false, + ) + ], ), - ], - ), + ), + ], ); } } diff --git a/webapp/frontend/lib/trade/trade_screen_order_form.dart b/webapp/frontend/lib/trade/trade_screen_order_form.dart index fe3047517..b92307e22 100644 --- a/webapp/frontend/lib/trade/trade_screen_order_form.dart +++ b/webapp/frontend/lib/trade/trade_screen_order_form.dart @@ -5,7 +5,6 @@ import 'package:get_10101/common/amount_text_input_form_field.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/logger/logger.dart'; import 'package:get_10101/trade/new_order_service.dart'; class NewOrderForm extends StatefulWidget { @@ -78,7 +77,7 @@ class _NewOrderForm extends State { label: "Leverage", textAlign: TextAlign.right, onChanged: (leverage) => setState(() { - _leverage = Leverage(int.parse(leverage)); + _leverage = Leverage(double.parse(leverage)); updateOrderValues(); }), ), diff --git a/webapp/src/api.rs b/webapp/src/api.rs index f3bf6663b..b193ca0aa 100644 --- a/webapp/src/api.rs +++ b/webapp/src/api.rs @@ -14,6 +14,8 @@ use native::api::WalletHistoryItemType; use native::ln_dlc; use native::trade::order::OrderState; use native::trade::order::OrderType; +use native::trade::position; +use native::trade::position::Position; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serde::Deserialize; @@ -194,3 +196,11 @@ pub async fn post_new_order(params: Json) -> Result Result>, AppError> { + let positions = position::handler::get_positions()? + .into_iter() + .collect::>(); + + Ok(Json(positions)) +} diff --git a/webapp/src/main.rs b/webapp/src/main.rs index e909402a9..05fad742a 100644 --- a/webapp/src/main.rs +++ b/webapp/src/main.rs @@ -5,6 +5,7 @@ mod subscribers; use crate::api::get_balance; use crate::api::get_onchain_payment_history; +use crate::api::get_positions; use crate::api::get_unused_address; use crate::api::post_new_order; use crate::api::send_payment; @@ -97,6 +98,7 @@ fn using_serve_dir(subscribers: Arc, network: Network) -> Router .route("/api/sendpayment", post(send_payment)) .route("/api/history", get(get_onchain_payment_history)) .route("/api/orders", post(post_new_order)) + .route("/api/positions", get(get_positions)) .route("/main.dart.js", get(main_dart_handler)) .route("/flutter.js", get(flutter_js)) .route("/index.html", get(index_handler))