From c4429d0e00218c98500426c3452dbe16cfd55de4 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 09:21:54 +0100 Subject: [PATCH 01/12] Start of trading toolkit position size calculator --- Cargo.toml | 2 + public/format.js | 15 + src/components.rs | 7 + src/components/display.rs | 1 + src/components/display/clipboard.rs | 51 ++ src/components/fields/currency.rs | 40 ++ src/components/fields/label.rs | 24 + src/components/fields/number.rs | 237 ++++++++ src/components/fields/toggle.rs | 48 ++ src/components/layout/footer.rs | 3 + src/model.rs | 6 + src/model/currency.rs | 149 +++++ src/model/trading/account.rs | 98 ++++ src/model/trading/position.rs | 364 +++++++++++++ src/pages.rs | 7 + src/pages/trading/position_size.rs | 814 ++++++++++++++++++++++++++++ style/main.css | 110 +++- 17 files changed, 1972 insertions(+), 4 deletions(-) create mode 100644 public/format.js create mode 100644 src/components/display/clipboard.rs create mode 100644 src/components/fields/currency.rs create mode 100644 src/components/fields/label.rs create mode 100644 src/components/fields/number.rs create mode 100644 src/components/fields/toggle.rs create mode 100644 src/model/currency.rs create mode 100644 src/model/trading/account.rs create mode 100644 src/model/trading/position.rs create mode 100644 src/pages/trading/position_size.rs diff --git a/Cargo.toml b/Cargo.toml index 466db38..c2f0d5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,8 @@ features = [ "LucideBug", "LucideCheck", "LucideCheckCircle", + "LucideClipboardCheck", + "LucideClipboardCopy", "LucideFlame", "LucideLink", "LucideList", diff --git a/public/format.js b/public/format.js new file mode 100644 index 0000000..c038ad6 --- /dev/null +++ b/public/format.js @@ -0,0 +1,15 @@ +export function formatNumber(value, thousands, places, prefix, suffix) { + const aval = Math.abs(value); + const neg = value < 0; + + const formatter = new Intl.NumberFormat(undefined, { + style: "decimal", + useGrouping: thousands ? "always" : false, + minimumFractionDigits: places, + }); + + const res = + (neg ? "-" : "") + (prefix || "") + formatter.format(aval) + (suffix || ""); + + return res; +} diff --git a/src/components.rs b/src/components.rs index b74cce3..a1629f5 100644 --- a/src/components.rs +++ b/src/components.rs @@ -7,3 +7,10 @@ pub mod layout; pub mod render; pub mod seo; pub mod title; + +pub mod fields { + pub mod currency; + pub mod label; + pub mod number; + pub mod toggle; +} diff --git a/src/components/display.rs b/src/components/display.rs index 1954e0b..7660853 100644 --- a/src/components/display.rs +++ b/src/components/display.rs @@ -1 +1,2 @@ pub mod bar_chart; +pub mod clipboard; diff --git a/src/components/display/clipboard.rs b/src/components/display/clipboard.rs new file mode 100644 index 0000000..5c39e10 --- /dev/null +++ b/src/components/display/clipboard.rs @@ -0,0 +1,51 @@ +use yew::{function_component, html, use_effect_with_deps, use_state, Callback, Html, Properties}; +use yew_hooks::use_clipboard; +use yew_icons::{Icon, IconId}; + +#[derive(Properties, PartialEq)] +pub struct CopyToClipboardProps { + pub value: String, + pub format: Option, +} + +#[function_component(CopyToClipboard)] +pub fn copy_to_clipboard(props: &CopyToClipboardProps) -> Html { + let copied = use_state(|| false); + let clipboard = use_clipboard(); + + let onclick = { + let value = props.value.clone(); + let format = props.format.clone(); + let copied = copied.clone(); + + Callback::from(move |_| { + clipboard.write(value.clone().into_bytes(), format.clone()); + copied.set(true); + }) + }; + + { + let copied = copied.clone(); + use_effect_with_deps( + move |_| { + copied.set(false); + }, + props.value.clone(), + ); + } + + html! { + + } +} diff --git a/src/components/fields/currency.rs b/src/components/fields/currency.rs new file mode 100644 index 0000000..d5ec773 --- /dev/null +++ b/src/components/fields/currency.rs @@ -0,0 +1,40 @@ +use web_sys::HtmlSelectElement; +use yew::{function_component, html, Callback, Html, Properties, TargetCast}; + +use crate::model::currency::{Currency, CURRENCIES}; + +#[derive(Properties, PartialEq)] +pub struct CurrencySelectProps { + pub value: Currency, + pub onchange: Callback, +} + +#[function_component(CurrencySelect)] +pub fn currency_select(props: &CurrencySelectProps) -> Html { + let onchange = { + let onchange = props.onchange.clone(); + + Callback::from(move |event: yew::Event| { + onchange.emit( + event + .target_dyn_into::() + .expect("target") + .value() + .parse::() + .expect("currency"), + ); + }) + }; + + html! { + + } +} diff --git a/src/components/fields/label.rs b/src/components/fields/label.rs new file mode 100644 index 0000000..73cde44 --- /dev/null +++ b/src/components/fields/label.rs @@ -0,0 +1,24 @@ +use yew::{classes, function_component, html, AttrValue, Children, Classes, Html, Properties}; + +#[derive(Properties, PartialEq)] +pub struct LabelProps { + pub title: AttrValue, + #[prop_or_default] + pub class: Classes, + #[prop_or_default] + pub children: Children, +} + +#[function_component(Label)] +pub fn label(props: &LabelProps) -> Html { + let class = classes!("flex", "flex-col", "gap-2", props.class.clone()); + + html! { +
+ + {props.children.clone()} +
+ } +} diff --git a/src/components/fields/number.rs b/src/components/fields/number.rs new file mode 100644 index 0000000..3c615d9 --- /dev/null +++ b/src/components/fields/number.rs @@ -0,0 +1,237 @@ +use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; +use web_sys::FocusEvent; +use yew::{ + classes, function_component, html, use_node_ref, use_state, AttrValue, Callback, Classes, Html, + Properties, TargetCast, +}; +use yew_hooks::use_timeout; +use yew_icons::{Icon, IconId}; + +#[wasm_bindgen(module = "/public/format.js")] +extern "C" { + #[wasm_bindgen(catch, js_name = formatNumber)] + pub fn format_number( + value: f64, + thousands: bool, + places: usize, + prefix: Option<&str>, + suffix: Option<&str>, + ) -> Result; +} + +#[derive(Properties, PartialEq)] +pub struct NumberProps { + pub icon_left: Option, + pub icon_right: Option, + + pub icon_left_class: Option, + pub icon_right_class: Option, + + #[prop_or_default] + pub class: Classes, + pub id: Option, + pub name: Option, + pub placeholder: Option, + + #[prop_or_default] + pub value: f64, + + #[prop_or_default] + pub disabled: bool, + #[prop_or_default] + pub thousands: bool, + #[prop_or_default] + pub places: usize, + pub prefix: Option, + pub suffix: Option, + + pub onfocus: Option>, + pub onblur: Option>, + pub onchange: Option>>, + pub oninput: Option>>, +} + +#[function_component(Number)] +pub fn number(props: &NumberProps) -> Html { + let mut classes = Classes::from("text-right"); + + let icon_left = if let Some(icon) = props.icon_left { + classes.push("icon-left"); + Some(html! { + + }) + } else { + None + }; + + let icon_right = if let Some(icon) = props.icon_right { + classes.push("icon-right"); + Some(html! { + + }) + } else { + None + }; + + classes.extend(props.class.clone()); + + let value = props.value; + + // Create a state variable that we will use to store the value that the user enters into the + // input element. We use this so that we only need to validate input when the user focuses away + // from the input (or presses ). The default value for this state is the initial value + // of the number field, formatted for reasonable input. + let input_value = { + let places = props.places; + use_state(move || { + format_number(value, false, places, None, None).expect("format_numbert to work") + }) + }; + + // We need to keep track of whether the field is focused, which we do with this state variable. + // When the user focuses and unfocuses the control, we toggle this state variable. + let focused = use_state(|| false); + + // We also want to keep track of a reference to our element, so that we can select the + // text when the user focuses the input. Doing this in the event handler is not having the + // desired effect without a small delay. + let input_ref = use_node_ref(); + + let timeout = { + let focused = focused.clone(); + let input_ref = input_ref.clone(); + + use_timeout( + move || { + if *focused { + input_ref + .cast::() + .expect(" not attached to reference") + .select(); + } + }, + 50, + ) + }; + + let parse_input = { + let places = props.places; + let input_value = input_value.clone(); + let onchange = props.onchange.clone(); + + Callback::from(move |()| { + let value = if let Ok(value) = (*input_value).parse::() { + // Update the text value of the input to match our parsed number. We only format to + // the desired number of places here, so we don't force the user to cursor around + // formatting characters and thousand separators. + input_value.set( + format_number(value, false, places, None, None) + .expect("Number formatting to work"), + ); + + Some(value) + } else { + None + }; + + if let Some(onchange) = &onchange { + onchange.emit(value); + } + }) + }; + + let onfocus = { + let timeout = timeout.clone(); + let focused = focused.clone(); + + Callback::from(move |_| { + focused.set(true); + timeout.reset(); + }) + }; + + let onblur = { + let focused = focused.clone(); + let parse_input = parse_input.clone(); + + Callback::from(move |_| { + focused.set(false); + parse_input.emit(()); + }) + }; + + let onchange = { Callback::from(move |_| parse_input.emit(())) }; + + let oninput = { + let input_value = input_value.clone(); + let oninput = props.oninput.clone(); + + Callback::from(move |event: yew::InputEvent| { + let Some(el) = event.target_dyn_into::() else { + log::error!("No element found in event"); + return; + }; + + let value = el.value(); + input_value.set(value.clone()); + + if let Some(oninput) = &oninput { + oninput.emit(value.parse::().ok()); + } + }) + }; + + // If the field is focused, then we want to allow the user to work with their own value, as a + // string. if the field is not focused, then we want to render the number value using the + // prefix, suffix, thousands separator and places. + let rendered = if *focused { + (*input_value).clone() + } else { + format_number( + value, + props.thousands, + props.places, + props.prefix.as_deref(), + props.suffix.as_deref(), + ) + .expect("format_number to work") + }; + + let id = props.id.clone(); + let name = props.name.clone(); + let placeholder = props.placeholder.clone(); + + let input = html! { + + }; + + if icon_left.is_some() || icon_right.is_some() { + html! { +
+ {icon_left} + {input} + {icon_right} +
+ } + } else { + input + } +} diff --git a/src/components/fields/toggle.rs b/src/components/fields/toggle.rs new file mode 100644 index 0000000..c9d6f45 --- /dev/null +++ b/src/components/fields/toggle.rs @@ -0,0 +1,48 @@ +use yew::{classes, function_component, html, AttrValue, Callback, Classes, Html, Properties}; + +#[derive(Properties, PartialEq)] +pub struct ToggleProps { + #[prop_or_default] + pub label: AttrValue, + pub value: bool, + #[prop_or_default] + pub classes: Classes, + #[prop_or_default] + pub disabled: bool, + pub onchange: Callback, +} + +#[function_component(Toggle)] +pub fn toggle(props: &ToggleProps) -> Html { + let onclick = { + let onchange = props.onchange.clone(); + let value = props.value; + let disabled = props.disabled; + + Callback::from(move |_| { + if disabled { + return; + } + + onchange.emit(!value); + }) + }; + + html! { +
+
+
+
+
+ if !props.label.is_empty() { +
+ {props.label.clone()} +
+ } +
+ } +} diff --git a/src/components/layout/footer.rs b/src/components/layout/footer.rs index 5a11bbc..0503fc1 100644 --- a/src/components/layout/footer.rs +++ b/src/components/layout/footer.rs @@ -65,6 +65,9 @@ pub fn footer(_: &FooterProps) -> Html { rel="noreferrer"> {"Mastodon"} + classes="hover:text-neutral-50" to={Route::PositionSize}> + {"Position Size Calculator"} + > classes="hover:text-neutral-50" to={Route::Analytics}> {"Analytics"} > diff --git a/src/model.rs b/src/model.rs index 7557cd2..b7c609b 100644 --- a/src/model.rs +++ b/src/model.rs @@ -6,8 +6,14 @@ use yew::{function_component, html, use_memo, Children, ContextProvider, Html, P macros::tags!("content/tags.yaml"); pub mod blog; +pub mod currency; pub mod pages; +pub mod trading { + pub mod account; + pub mod position; +} + #[derive(Properties, PartialEq)] pub struct ProvideTagsProps { #[prop_or_default] diff --git a/src/model/currency.rs b/src/model/currency.rs new file mode 100644 index 0000000..61fbb30 --- /dev/null +++ b/src/model/currency.rs @@ -0,0 +1,149 @@ +use std::{collections::HashMap, fmt::Display, str::FromStr}; + +use gloo::net::http::Request; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Currency { + AUD, + CAD, + EUR, + GBP, + JPY, + USD, +} + +impl Default for Currency { + fn default() -> Self { + Self::USD + } +} + +impl Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Currency::AUD => "AUD", + Currency::CAD => "CAD", + Currency::EUR => "EUR", + Currency::GBP => "GBP", + Currency::JPY => "JPY", + Currency::USD => "USD", + } + ) + } +} + +impl FromStr for Currency { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "AUD" => Ok(Currency::AUD), + "CAD" => Ok(Currency::CAD), + "EUR" => Ok(Currency::EUR), + "GBP" => Ok(Currency::GBP), + "JPY" => Ok(Currency::JPY), + "USD" => Ok(Currency::USD), + _ => Err(()), + } + } +} + +impl Currency { + pub fn symbol(&self) -> &'static str { + match self { + Currency::AUD => "$", + Currency::CAD => "$", + Currency::EUR => "€", + Currency::GBP => "£", + Currency::JPY => "¥", + Currency::USD => "$", + } + } +} + +pub static CURRENCIES: &[Currency] = &[ + Currency::AUD, + Currency::CAD, + Currency::EUR, + Currency::GBP, + Currency::JPY, + Currency::USD, +]; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ExchangeRates { + /// The base currency + pub base: Currency, + /// The rates converting from `base` to the target currency + pub rates: HashMap, +} + +impl Default for ExchangeRates { + fn default() -> Self { + Self::new(Currency::default()) + } +} + +impl ExchangeRates { + pub fn new(base: Currency) -> Self { + let mut rates = HashMap::new(); + rates.insert(base, 1.0); + Self { base, rates } + } +} + +#[derive(Debug, Deserialize)] +struct ExchangeRateResult { + success: bool, + base: String, + date: String, + rates: HashMap, +} + +pub async fn get_exchange_rates( + base: Currency, + target: Option, +) -> Result { + let symbols = target.as_ref().map(Currency::to_string).unwrap_or_else(|| { + CURRENCIES + .iter() + .map(Currency::to_string) + .collect::>() + .join(",") + }); + + let res = Request::get(&format!( + "https://api.exchangerate.host/latest?base={base}&symbols={symbols}" + )) + .send() + .await + .map_err(|err| { + log::error!("Failed to send request to api.exchangerate.host: {err:?}"); + "Failed to send request to api.exchangerate.host" + })? + .json::() + .await + .map_err(|err| { + log::error!("Failed to parse response from api.exchangerate.host: {err:?}"); + "Failed to parse response from api.exchangerate.host" + })?; + + let mut rates = HashMap::new(); + for (symbol, rate) in res.rates { + match symbol.parse() { + Ok(currency) => { + rates.insert(currency, rate); + } + + Err(err) => { + log::error!("Failed to parse currency symbol '{symbol}' from api.exchangerate.host: {err:?}"); + } + } + } + + Ok(ExchangeRates { base, rates }) +} diff --git a/src/model/trading/account.rs b/src/model/trading/account.rs new file mode 100644 index 0000000..290da27 --- /dev/null +++ b/src/model/trading/account.rs @@ -0,0 +1,98 @@ +use std::rc::Rc; + +use gloo::storage::Storage; +use serde::{Deserialize, Serialize}; +use yew::Reducible; + +use crate::model::currency::{Currency, ExchangeRates}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Account { + pub places: usize, + pub currency: Currency, + pub exchange_rates: ExchangeRates, + pub amount: f64, + pub margin_risk: f64, + pub position_risk: f64, +} + +impl Default for Account { + fn default() -> Self { + let currency = Currency::GBP; + + Self { + places: 4, + currency, + exchange_rates: ExchangeRates::new(currency), + amount: 500.0, + margin_risk: 0.01, + position_risk: 0.01, + } + } +} + +pub enum AccountAction { + SetPlaces { places: usize }, + SetCurrency { currency: Currency }, + SetExchangeRates { exchange_rates: ExchangeRates }, + SetAmount { amount: f64 }, + SetMarginRisk { risk: f64 }, + SetPositionRisk { risk: f64 }, +} + +impl Reducible for Account { + type Action = AccountAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + match action { + AccountAction::SetPlaces { places } => Self { + places, + ..(*self).clone() + }, + + AccountAction::SetCurrency { currency } => Self { + currency, + ..(*self).clone() + }, + + AccountAction::SetExchangeRates { exchange_rates } => Self { + exchange_rates, + ..(*self).clone() + }, + + AccountAction::SetAmount { amount } => Self { + amount, + ..(*self).clone() + }, + + AccountAction::SetMarginRisk { risk } => Self { + margin_risk: risk, + ..(*self).clone() + }, + + AccountAction::SetPositionRisk { risk } => Self { + position_risk: risk, + ..(*self).clone() + }, + } + .into() + } +} + +impl Account { + pub fn load() -> Self { + match gloo::storage::LocalStorage::get("trading.account") { + Ok(stored) => stored, + Err(err) => { + log::error!("Failed to retrieve trading account from local storage: {err:?}"); + return Self::default(); + } + } + } + + pub fn save(&self) { + if let Err(err) = gloo::storage::LocalStorage::set("trading.account", self) { + log::error!("Failed to store trading account in local storage: {err:?}"); + } + } +} diff --git a/src/model/trading/position.rs b/src/model/trading/position.rs new file mode 100644 index 0000000..2e191e7 --- /dev/null +++ b/src/model/trading/position.rs @@ -0,0 +1,364 @@ +use std::{fmt::Display, rc::Rc, str::FromStr}; + +use yew::Reducible; + +use crate::model::currency::Currency; + +use super::account::Account; + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum Direction { + Buy, + Sell, +} + +impl Display for Direction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Buy => write!(f, "Buy"), + Self::Sell => write!(f, "Sell"), + } + } +} + +impl FromStr for Direction { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "Buy" => Ok(Self::Buy), + "Sell" => Ok(Self::Sell), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Position { + pub position_currency: Currency, + pub quote_currency: Currency, + pub conversion: f64, + pub open_price: f64, + pub quantity: Option, + pub direction: Direction, + pub margin: f64, + pub take_profit: Option, + pub stop_loss: Option, +} + +impl Default for Position { + fn default() -> Self { + Self { + position_currency: Currency::GBP, + quote_currency: Currency::GBP, + conversion: 1.0, + open_price: 0.0, + quantity: None, + direction: Direction::Buy, + margin: 0.05, + take_profit: None, + stop_loss: None, + } + } +} + +pub enum PositionAction { + SetPositionCurrency { currency: Currency }, + SetQuoteCurrency { currency: Currency }, + SetConversion { conversion: f64 }, + SetOpenPrice { price: f64 }, + SetQuantity { quantity: Option }, + SetDirection { direction: Direction }, + SetMargin { margin: f64 }, + SetTakeProfit { price: Option }, + SetStopLoss { price: Option }, +} + +impl Reducible for Position { + type Action = PositionAction; + + fn reduce(self: Rc, action: Self::Action) -> Rc { + match action { + PositionAction::SetPositionCurrency { currency } => Self { + position_currency: currency, + ..(*self).clone() + }, + + PositionAction::SetQuoteCurrency { currency } => Self { + quote_currency: currency, + ..(*self).clone() + }, + + PositionAction::SetConversion { conversion } => Self { + conversion, + ..(*self).clone() + }, + + PositionAction::SetOpenPrice { price } => Self { + open_price: price, + ..(*self).clone() + }, + + PositionAction::SetQuantity { quantity } => Self { + quantity, + ..(*self).clone() + }, + + PositionAction::SetDirection { direction } => Self { + direction, + ..(*self).clone() + }, + + PositionAction::SetMargin { margin } => Self { + margin, + ..(*self).clone() + }, + + PositionAction::SetTakeProfit { price } => Self { + take_profit: price, + ..(*self).clone() + }, + + PositionAction::SetStopLoss { price } => Self { + stop_loss: price, + ..(*self).clone() + }, + } + .into() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PositionSize { + /// Funds available under margin risk (in account currency) + pub available: f64, + /// Funds available under margin risk (in position currency) + pub available_position: f64, + /// Funds available under margin risk (in quote currency) + pub available_quote: f64, + /// Margin available under margin risk (in account currency) + pub margin: f64, + /// Margin available (in position currency) + pub margin_position: f64, + /// margin available (in quote currency) + pub margin_quote: f64, + /// Quantity affordable at position price (in units) + pub affordable: f64, + /// Optional actual position size margin risk + pub actual: Option, +} + +impl PositionSize { + pub fn compute(account: &Account, position: &Position) -> Self { + let p_rate = account + .exchange_rates + .rates + .get(&position.position_currency) + .copied() + .unwrap_or(1.0); + let q_rate = position.conversion; + let available = account.amount * account.margin_risk; + let available_position = available * p_rate; + let available_quote = available_position * q_rate; + + let margin = available + / if position.margin == 0.0 { + 1.0 + } else { + position.margin + }; + + let margin_position = margin * p_rate; + let margin_quote = margin_position * q_rate; + + let affordable = if position.open_price == 0.0 { + 0.0 + } else { + margin_quote / position.open_price + }; + + Self { + available, + available_position, + available_quote, + margin, + margin_position, + margin_quote, + affordable, + actual: position.quantity.map(|quantity| { + ActualPositionSize::compute(account, position, quantity, p_rate, q_rate) + }), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ActualPositionSize { + /// The actual quote cost (in quote currency) + pub cost_quote: f64, + /// The actual position cost (in position currency) + pub cost_position: f64, + /// The actual position cost (in account currency) + pub cost: f64, + /// The account margin required (percent) + pub margin: f64, +} + +impl ActualPositionSize { + pub fn compute( + account: &Account, + position: &Position, + quantity: f64, + q_rate: f64, + p_rate: f64, + ) -> Self { + let cost_quote = quantity * position.open_price * position.margin; + let cost_position = cost_quote / q_rate; + let cost = cost_position / p_rate; + let margin = cost / account.amount; + + Self { + cost_quote, + cost_position, + cost, + margin, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StopLoss { + /// Funds available under position risk (in account currency) + pub available: f64, + /// Funds available under position risk (in position currency) + pub available_position: f64, + /// Funds available under position risk (in quote currency) + pub available_quote: f64, + /// Specified position size + pub quantity: f64, + /// Required stop-loss distance + pub distance: f64, + /// Optional actual stop-loss assessment + pub actual: Option, +} + +impl StopLoss { + pub fn compute(account: &Account, position: &Position, quantity: f64) -> Self { + let p_rate = account + .exchange_rates + .rates + .get(&position.position_currency) + .copied() + .unwrap_or(1.0); + let q_rate = position.conversion; + let available = account.amount * account.position_risk; + let available_position = available * p_rate; + let available_quote = available_position * q_rate; + let distance = if quantity == 0.0 { + 0.0 + } else { + available_quote / quantity + }; + + Self { + available, + available_position, + available_quote, + quantity, + distance, + actual: if let Some(stop_loss) = position.stop_loss { + Some(ActualStopLoss::compute( + account, position, quantity, p_rate, q_rate, stop_loss, + )) + } else { + None + }, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ActualStopLoss { + /// The actual stop-loss distance (in position currency) + pub distance: f64, + /// The possible loss + pub loss: f64, + /// The actual position risk (percent) + pub risk: f64, +} + +impl ActualStopLoss { + pub fn compute( + account: &Account, + position: &Position, + quantity: f64, + p_rate: f64, + q_rate: f64, + stop_loss: f64, + ) -> Self { + let distance = match position.direction { + Direction::Buy => position.open_price - stop_loss, + Direction::Sell => stop_loss - position.open_price, + }; + + let loss = (distance * quantity) / (p_rate * q_rate); + let risk = loss / account.amount; + + Self { + distance, + loss, + risk, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct StopLossQuantity { + /// Funds available under position risk (in account currency) + pub available: f64, + /// Funds available under position risk (in position currency) + pub available_position: f64, + /// Funds available under position risk (in quote currency) + pub available_quote: f64, + /// Computed stop loss distance (in position currency) + pub distance: f64, + /// Amount that can be bought at the given stop loss (in units) + pub affordable: f64, + /// Required margin for that amount (in account currency) + pub margin: f64, +} + +impl StopLossQuantity { + pub fn compute(account: &Account, position: &Position) -> Self { + let distance = if let Some(stop_loss) = position.stop_loss { + match position.direction { + Direction::Buy => position.open_price - stop_loss, + Direction::Sell => stop_loss - position.open_price, + } + } else { + 0.0 + }; + + let p_rate = account + .exchange_rates + .rates + .get(&position.position_currency) + .copied() + .unwrap_or(1.0); + let q_rate = position.conversion; + let available = account.amount * account.position_risk; + let available_position = available * p_rate; + let available_quote = available_position * q_rate; + let affordable = available_quote / distance; + let margin = (affordable * position.open_price * position.margin) / (p_rate * q_rate); + + Self { + available, + available_position, + available_quote, + distance, + affordable, + margin, + } + } +} diff --git a/src/pages.rs b/src/pages.rs index 11e5b5a..35b163b 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -13,6 +13,10 @@ mod analytics { pub mod dashboard; } +mod trading { + pub mod position_size; +} + #[derive(Debug, Clone, PartialEq, Sequence, Routable)] pub enum Route { #[at("/")] @@ -27,6 +31,8 @@ pub enum Route { Disclaimer, #[at("/analytics")] Analytics, + #[at("/trading/position-size")] + PositionSize, #[not_found] #[at("/404")] NotFound, @@ -46,6 +52,7 @@ impl Route { Self::Disclaimer => html! { }, Self::NotFound => html! { }, Self::Analytics => html! { }, + Self::PositionSize => html! { }, } } } diff --git a/src/pages/trading/position_size.rs b/src/pages/trading/position_size.rs new file mode 100644 index 0000000..96c5116 --- /dev/null +++ b/src/pages/trading/position_size.rs @@ -0,0 +1,814 @@ +use web_sys::HtmlSelectElement; +use yew::{ + function_component, html, use_context, use_effect, use_effect_with_deps, use_reducer, Callback, + Children, ContextProvider, Html, Properties, TargetCast, UseReducerHandle, +}; +use yew_hooks::{use_async, UseAsyncHandle}; + +use crate::{ + components::fields::{ + currency::CurrencySelect, + label::Label, + number::{format_number, Number}, + toggle::Toggle, + }, + model::{ + currency::get_exchange_rates, + trading::{ + account::{Account, AccountAction}, + position::{Direction, Position, PositionAction, PositionSize, StopLossQuantity}, + }, + }, +}; + +type AccountHandle = UseReducerHandle; +type PositionHandle = UseReducerHandle; + +#[function_component(AccountInfo)] +fn account_info() -> Html { + let account = use_context::().expect("AccountHandle"); + + let currency_change = { + let account = account.clone(); + Callback::from(move |currency| account.dispatch(AccountAction::SetCurrency { currency })) + }; + + let amount_change = { + let account = account.clone(); + Callback::from(move |amount| { + if let Some(amount) = amount { + account.dispatch(AccountAction::SetAmount { amount }) + } + }) + }; + + let margin_risk_change = { + let account = account.clone(); + Callback::from(move |risk| { + if let Some(risk) = risk { + account.dispatch(AccountAction::SetMarginRisk { risk: risk / 100.0 }) + } + }) + }; + + let position_risk_change = { + let account = account.clone(); + Callback::from(move |risk| { + if let Some(risk) = risk { + account.dispatch(AccountAction::SetPositionRisk { risk: risk / 100.0 }) + } + }) + }; + + let places_change = { + let account = account.clone(); + Callback::from(move |places| { + if let Some(places) = places { + let places = places as usize; + account.dispatch(AccountAction::SetPlaces { places }) + } + }) + }; + + html! { +
+

{"Account Information"}

+
+ + + + + +
+
+ } +} + +#[function_component(PositionInfo)] +fn position_info() -> Html { + let account = use_context::().expect("AccountHandle"); + let position = use_context::().expect("PositionHandle"); + + let position_currency_change = { + let position = position.clone(); + Callback::from(move |currency| { + position.dispatch(PositionAction::SetPositionCurrency { currency }) + }) + }; + + let quote_currency_change = { + let position = position.clone(); + Callback::from(move |currency| { + position.dispatch(PositionAction::SetQuoteCurrency { currency }) + }) + }; + + let margin_change = { + let position = position.clone(); + Callback::from(move |margin| { + if let Some(margin) = margin { + position.dispatch(PositionAction::SetMargin { + margin: margin / 100.0, + }); + } + }) + }; + + let open_price_change = { + let position = position.clone(); + Callback::from(move |price| { + if let Some(price) = price { + position.dispatch(PositionAction::SetOpenPrice { price }); + } + }) + }; + + let direction_change = { + let position = position.clone(); + Callback::from(move |event: yew::Event| { + let direction = event + .target_dyn_into::() + .expect("target") + .value() + .parse::() + .expect("direction"); + position.dispatch(PositionAction::SetDirection { direction }) + }) + }; + + let quantity_toggle = { + let position = position.clone(); + Callback::from(move |enabled| { + position.dispatch(PositionAction::SetQuantity { + quantity: if enabled { Some(1.0) } else { None }, + }); + }) + }; + + let quantity_change = { + let position = position.clone(); + Callback::from(move |quantity| { + if let Some(quantity) = quantity { + if position.quantity.is_some() { + position.dispatch(PositionAction::SetQuantity { + quantity: Some(quantity), + }); + } + } + }) + }; + + let affordable_click = { + let account = account.clone(); + let position = position.clone(); + Callback::from(move |_| { + if position.quantity.is_none() { + return; + } + + let PositionSize { affordable, .. } = PositionSize::compute(&account, &position); + position.dispatch(PositionAction::SetQuantity { + quantity: Some(affordable), + }); + }) + }; + + let stop_loss_click = { + let account = account.clone(); + let position = position.clone(); + Callback::from(move |_| { + if position.quantity.is_none() { + return; + } + + let StopLossQuantity { affordable, .. } = + StopLossQuantity::compute(&account, &position); + position.dispatch(PositionAction::SetQuantity { + quantity: Some(affordable), + }); + }) + }; + + let stop_loss_toggle = { + let position = position.clone(); + Callback::from(move |enabled| { + position.dispatch(PositionAction::SetStopLoss { + price: if enabled { + Some(position.open_price) + } else { + None + }, + }); + }) + }; + + let stop_loss_change = { + let position = position.clone(); + Callback::from(move |price| { + if let Some(price) = price { + if position.stop_loss.is_some() { + position.dispatch(PositionAction::SetStopLoss { price: Some(price) }); + } + } + }) + }; + + let stop_loss_distance_change = { + let position = position.clone(); + Callback::from(move |distance| { + if let Some(distance) = distance { + if position.stop_loss.is_some() { + position.dispatch(PositionAction::SetStopLoss { + price: Some(match position.direction { + Direction::Buy => position.open_price - distance, + Direction::Sell => position.open_price + distance, + }), + }); + } + } + }) + }; + + let take_profit_toggle = { + let position = position.clone(); + Callback::from(move |enabled| { + position.dispatch(PositionAction::SetTakeProfit { + price: if enabled { + Some(position.open_price) + } else { + None + }, + }); + }) + }; + + let take_profit_change = { + let position = position.clone(); + Callback::from(move |price| { + if let Some(price) = price { + if position.take_profit.is_some() { + position.dispatch(PositionAction::SetTakeProfit { price: Some(price) }); + } + } + }) + }; + + let take_profit_distance_change = { + let position = position.clone(); + Callback::from(move |distance| { + if let Some(distance) = distance { + if position.take_profit.is_some() { + position.dispatch(PositionAction::SetTakeProfit { + price: Some(match position.direction { + Direction::Buy => position.open_price + distance, + Direction::Sell => position.open_price - distance, + }), + }); + } + } + }) + }; + + let tp_distance = match position.take_profit { + None => 0.0, + Some(tp) => match position.direction { + Direction::Buy => tp - position.open_price, + Direction::Sell => position.open_price - tp, + }, + }; + + let sl_distance = match position.stop_loss { + None => 0.0, + Some(sl) => match position.direction { + Direction::Buy => position.open_price - sl, + Direction::Sell => sl - position.open_price, + }, + }; + + let position_exchange = if account.currency != position.position_currency { + format!( + "Position ({}→{} {})", + account.currency, + position.position_currency, + format_number( + account + .exchange_rates + .rates + .get(&position.position_currency) + .copied() + .unwrap_or(0.0), + false, + account.places, + Some(position.position_currency.symbol()), + None + ) + .expect("format_number") + ) + } else { + "Position".to_string() + }; + + let quote_exchange = if position.quote_currency != position.position_currency { + format!( + "Quote ({}→{} {})", + position.position_currency, + position.quote_currency, + format_number( + position.conversion, + false, + account.places, + Some(position.quote_currency.symbol()), + None + ) + .expect("format_number") + ) + } else { + "Quote".to_string() + }; + + let position_margin = if position.margin != 0.0 { + format!("Position Margin ({:.0}x leverage)", 1.0 / position.margin) + } else { + "Position Margin".to_string() + }; + + html! { +
+

{"Position Information"}

+
+
+ + +
+ + + + +
+ + +
+ + + + +
+
+ } +} + +#[function_component(ReportPositionSize)] +fn report_position_size() -> Html { + let account = use_context::().expect("AccountHandle"); + let position = use_context::().expect("PositionHandle"); + + let PositionSize { + available, + available_position, + available_quote, + margin, + margin_position, + margin_quote, + affordable, + actual, + } = PositionSize::compute(&account, &position); + + let ac = account.currency.symbol(); + let pc = position.position_currency.symbol(); + let qc = position.quote_currency.symbol(); + let ap = account.currency != position.position_currency; + let pq = position.position_currency != position.quote_currency; + + let actual = if let Some(actual) = actual { + html! { +
+ + + + + + + + + + + + + + if pq { + + + + } + if ap { + + + + } + +
{"Actual Quantity"}
+ {"Actual cost of opening a position of "} + {format_number( + position.quantity.unwrap_or_default(), + true, 2, None, None + ).expect("format_number")} + {" units at "} + {format_number( + position.open_price, true, + account.places, Some(qc), None + ).expect("format_number")} + + {format_number( + position.quantity.unwrap_or_default() * position.open_price, + true, account.places, Some(qc), None + ).expect("format_number")} +
+ {"Amount of margin required at "} + {format_number( + position.margin * 100.0, true, + 2, None, Some("%") + ).expect("format_number")} + {format!(" position margin ({:.0}x leverage)", + 1.0 / if position.margin == 0.0 { + 1.0 + } else { + position.margin + }, + )} + + {format_number( + actual.cost_quote, true, + account.places, Some(qc), None + ).expect("format_number")} +
+ + {format_number( + actual.cost_position, true, + account.places, Some(pc), None + ).expect("format_number")} +
+ + {format_number( + actual.cost, true, + account.places, Some(ac), None + ).expect("format_number")} +
+
+ } + } else { + html! {} + }; + + html! { +
+

{"Position Size Information"}

+
+ + + + + + + + + + if ap { + + + + + } + if pq { + + + + + } + +
{"Margin Risk"}
+ {"Amount of account available under margin risk"} + + {format_number(available, true, + account.places, Some(ac), None + ).expect("format_number")} +
+ {"Available account under margin risk in the position currency"} + + {format_number( + available_position, true, + account.places, Some(pc), None + ).expect("format_number")} +
+ {"Available account under margin risk in the quote currency"} + + {format_number( + available_quote, true, + account.places, Some(qc), None + ).expect("format_number")} +
+
+ +
+ + + + + + + + + + if ap { + + + + + } + if pq { + + + + + } + + + + + +
{"Position Margin and Amount"}
+ {"Available amount with a "} + {format_number( + position.margin * 100.0, + true, 2, None, Some("%") + ).expect("format_number")} + {" position margin"} + + {format_number( + margin, true, + account.places, Some(ac), None + ).expect("format_number")} +
+ {"Available amount under position margin in position currency"} + + {format_number( + margin_position, true, + account.places, Some(pc), None + ).expect("format_number")} +
+ {"Available amount under position margin in quote currency"} + + {format_number( + margin_quote, true, + account.places, Some(qc), None + ).expect("format_number")} +
+ {"Position size available at open price of "} + {format_number( + position.open_price, true, + account.places, Some(qc), None + ).expect("format_number")} + {" with margin of "} + {format_number( + margin_quote, true, + account.places, Some(qc), None + ).expect("format_number")} + + {format_number( + affordable, true, + 2, None, Some(" units") + ).expect("format_number")} +
+
+ + {actual} +
+ } +} + +#[derive(Properties, PartialEq)] +struct AccountProviderProps { + #[prop_or_default] + pub children: Children, +} + +#[function_component(AccountProvider)] +fn account_provider(props: &AccountProviderProps) -> Html { + let account = use_reducer(Account::default); + + let get_exchange_rates: UseAsyncHandle<(), &'static str> = { + let account = account.clone(); + use_async(async move { + let rates = get_exchange_rates(account.currency, None).await?; + account.dispatch(AccountAction::SetExchangeRates { + exchange_rates: rates, + }); + Ok(()) + }) + }; + + use_effect_with_deps( + move |_| { + get_exchange_rates.run(); + }, + account.currency, + ); + + html! { + context={account}> + {props.children.clone()} + > + } +} + +#[derive(Properties, PartialEq)] +struct PositionProviderProps { + #[prop_or_default] + pub children: Children, +} + +#[function_component(PositionProvider)] +fn position_provider(props: &PositionProviderProps) -> Html { + let position = use_reducer(Position::default); + + let get_exchange_rates: UseAsyncHandle<(), &'static str> = { + let position = position.clone(); + use_async(async move { + let rates = + get_exchange_rates(position.position_currency, Some(position.quote_currency)) + .await?; + position.dispatch(PositionAction::SetConversion { + conversion: rates + .rates + .get(&position.quote_currency) + .copied() + .unwrap_or(1.0), + }); + Ok(()) + }) + }; + + use_effect_with_deps( + move |_| { + get_exchange_rates.run(); + }, + (position.position_currency, position.quote_currency), + ); + + html! { + context={position}> + {props.children.clone()} + > + } +} + +#[function_component(Page)] +pub fn page() -> Html { + html! { + + +
+
+ + +
+
+ +
+
+
+
+ } +} diff --git a/style/main.css b/style/main.css index 42b2e05..6c78003 100644 --- a/style/main.css +++ b/style/main.css @@ -1,5 +1,9 @@ @import "tailwind.css"; +:root { + --toggle-size: 2rem; +} + @layer components { body { @apply bg-white text-neutral-800; @@ -20,6 +24,10 @@ } } + label { + @apply font-semibold; + } + select, input[type="text"], input[type="number"], @@ -27,7 +35,9 @@ @apply border-primary rounded-md; @apply text-neutral-800 placeholder:text-neutral-300; @apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700; + @apply disabled:bg-gray-200 dark:disabled:bg-zinc-900 dark:disabled:text-neutral-500; @apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400; + @apply transition; &:disabled { @apply text-neutral-500 dark:text-neutral-500; @@ -35,6 +45,80 @@ } } + .input-icons { + @apply relative; + + svg:first-child, + svg:last-child { + @apply pointer-events-none absolute top-1/2 -mt-2.5 text-gray-300 dark:text-gray-500; + } + + svg:first-child { + @apply left-3; + } + + svg:last-child { + @apply right-3; + } + + select, + input[type="text"], + input[type="number"], + input[type="password"] { + &.icon-left { + @apply pl-10; + } + + &.icon-right { + @apply pr-10; + } + } + } + + .toggle { + @apply block relative cursor-pointer; + + width: calc(2 * var(--toggle-size)); + height: var(--toggle-size); + + &.active { + .toggle-background { + @apply bg-slate-800 dark:bg-indigo-300; + } + + .toggle-inner { + right: 0rem; + } + } + + .toggle-background { + @apply absolute bg-slate-300 dark:bg-gray-600; + + width: calc(2 * var(--toggle-size)); + height: calc(0.5 * var(--toggle-size)); + top: calc(0.25 * var(--toggle-size)); + + border-radius: calc(0.5 * var(--toggle-size)); + + transition: background-color 0.125s ease-in-out; + } + + .toggle-inner { + @apply absolute bg-white; + + width: var(--toggle-size); + height: var(--toggle-size); + top: 0; + right: var(--toggle-size); + + border-radius: calc(0.5 * var(--toggle-size)); + border: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25); + + transition: right 0.125s ease-in-out; + } + } + .markdown { @apply flex flex-col; @apply font-sans font-normal text-lg print:text-base; @@ -259,6 +343,10 @@ @apply text-neutral-600 dark:text-neutral-400; } + div.table { + @apply mb-6; + } + blockquote { @apply w-full mb-6 border-l-4 border-l-blue-400 px-8; @@ -360,12 +448,11 @@ } table { - @apply min-w-full mb-6 border-collapse table-auto; + @apply min-w-full border-collapse table-auto; thead { @apply bg-transparent dark:bg-neutral-800; @apply dark:text-white; - @apply border-b border-neutral-500; tr { th { @@ -388,8 +475,6 @@ tbody { tr { - @apply border-b border-neutral-400 dark:border-neutral-600; - td { @apply whitespace-nowrap px-6 py-4; @@ -414,5 +499,22 @@ @apply px-3 py-2; } } + + &.tighter { + thead tr th, + tbody tr td { + @apply px-1 py-1; + } + } + + &:not(.borderless) { + thead { + @apply border-b border-neutral-500; + } + + tbody tr { + @apply border-b border-neutral-400 dark:border-neutral-600; + } + } } /* table */ } -- 2.45.2 From 67a16dcdf15400cda1edeef29a78d7b48dc59858 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 10:28:14 +0100 Subject: [PATCH 02/12] Fix issue with state/prop sync --- src/components/fields/number.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/fields/number.rs b/src/components/fields/number.rs index 3c615d9..b31cc30 100644 --- a/src/components/fields/number.rs +++ b/src/components/fields/number.rs @@ -1,8 +1,8 @@ use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; use web_sys::FocusEvent; use yew::{ - classes, function_component, html, use_node_ref, use_state, AttrValue, Callback, Classes, Html, - Properties, TargetCast, + classes, function_component, html, use_effect_with_deps, use_node_ref, use_state, AttrValue, + Callback, Classes, Html, Properties, TargetCast, }; use yew_hooks::use_timeout; use yew_icons::{Icon, IconId}; @@ -103,6 +103,23 @@ pub fn number(props: &NumberProps) -> Html { // desired effect without a small delay. let input_ref = use_node_ref(); + // When the value passed to this field changes, we want to update our state. + { + let input_value = input_value.clone(); + let focused = focused.clone(); + use_effect_with_deps( + move |(value, places)| { + if !*focused { + input_value.set( + format_number(*value, false, *places, None, None) + .expect("format_number to work"), + ); + } + }, + (value, props.places), + ); + } + let timeout = { let focused = focused.clone(); let input_ref = input_ref.clone(); -- 2.45.2 From 2ddd122728639f28116f314bfa394ff2527c1c00 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 10:29:42 +0100 Subject: [PATCH 03/12] Obey Clippy --- src/model/currency.rs | 3 --- src/model/trading/account.rs | 2 +- src/model/trading/position.rs | 10 +++------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/model/currency.rs b/src/model/currency.rs index 61fbb30..7ca7bcc 100644 --- a/src/model/currency.rs +++ b/src/model/currency.rs @@ -98,9 +98,6 @@ impl ExchangeRates { #[derive(Debug, Deserialize)] struct ExchangeRateResult { - success: bool, - base: String, - date: String, rates: HashMap, } diff --git a/src/model/trading/account.rs b/src/model/trading/account.rs index 290da27..c48c364 100644 --- a/src/model/trading/account.rs +++ b/src/model/trading/account.rs @@ -85,7 +85,7 @@ impl Account { Ok(stored) => stored, Err(err) => { log::error!("Failed to retrieve trading account from local storage: {err:?}"); - return Self::default(); + Self::default() } } } diff --git a/src/model/trading/position.rs b/src/model/trading/position.rs index 2e191e7..ff1bc2b 100644 --- a/src/model/trading/position.rs +++ b/src/model/trading/position.rs @@ -266,13 +266,9 @@ impl StopLoss { available_quote, quantity, distance, - actual: if let Some(stop_loss) = position.stop_loss { - Some(ActualStopLoss::compute( - account, position, quantity, p_rate, q_rate, stop_loss, - )) - } else { - None - }, + actual: position.stop_loss.map(|stop_loss| { + ActualStopLoss::compute(account, position, quantity, p_rate, q_rate, stop_loss) + }), } } } -- 2.45.2 From a36893f41097c607941cf5565f0cf3ca91f185d2 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:17:08 +0100 Subject: [PATCH 04/12] Better unification of control styles --- style/main.css | 91 ++++++++++++++++++++++++++++++++++++++-------- tailwind.config.js | 1 + 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/style/main.css b/style/main.css index 6c78003..6b4a5cd 100644 --- a/style/main.css +++ b/style/main.css @@ -2,6 +2,7 @@ :root { --toggle-size: 2rem; + --primary-color: #13304e; } @layer components { @@ -11,13 +12,21 @@ } .button { - @apply inline-flex items-center justify-center border border-transparent; + @apply inline-flex items-center justify-center; + @apply border border-transparent rounded-md; + @apply px-4 py-2; - @apply rounded-md shadow-sm text-sm text-gray-300 bg-primary; - @apply disabled:bg-slate-300 dark:disabled:bg-gray-600 dark:disabled:text-gray-400; - @apply hover:text-white dark:disabled:hover:text-gray-400; - @apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400; - @apply transition-colors; + @apply text-sm; + + @apply text-neutral-200 hover:text-neutral-100; + @apply disabled:text-gray-400 dark:disabled:text-gray-800; + + @apply bg-primary; + @apply disabled:bg-gray-200 dark:disabled:bg-gray-600; + @apply active:bg-primary-light; + + @apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary; + @apply transition; > svg { @apply mr-1; @@ -33,16 +42,17 @@ input[type="number"], input[type="password"] { @apply border-primary rounded-md; - @apply text-neutral-800 placeholder:text-neutral-300; - @apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700; - @apply disabled:bg-gray-200 dark:disabled:bg-zinc-900 dark:disabled:text-neutral-500; - @apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400; - @apply transition; + @apply disabled:border-transparent; - &:disabled { - @apply text-neutral-500 dark:text-neutral-500; - @apply dark:bg-zinc-900; - } + @apply text-neutral-800 placeholder:text-neutral-300; + @apply dark:text-neutral-200 placeholder:text-neutral-500; + @apply disabled:text-gray-400 dark:disabled:text-gray-800; + + @apply bg-white dark:bg-zinc-800; + @apply disabled:bg-gray-200 dark:disabled:bg-gray-600; + + @apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary; + @apply transition; } .input-icons { @@ -119,6 +129,57 @@ } } + .tooltip { + @apply absolute z-10 w-max max-w-xs min-h-fit p-4; + @apply text-sm text-center text-neutral-50 whitespace-pre-wrap; + @apply bg-primary border border-primary rounded-md shadow-md; + + &:after { + @apply absolute w-0 h-0; + content: ""; + } + + &.top { + @apply bottom-full left-1/2 -translate-x-1/2 mb-4; + &:after { + @apply top-full left-1/2 -ml-4; + border-left: 1rem solid transparent; + border-right: 1rem solid transparent; + border-top: 1rem solid var(--primary-color); + } + } + + &.bottom { + @apply top-full left-1/2 -translate-x-1/2 mt-4; + &:after { + @apply bottom-full left-1/2 -ml-4; + border-left: 1rem solid transparent; + border-right: 1rem solid transparent; + border-bottom: 1rem solid var(--primary-color); + } + } + + &.left { + @apply -top-3 right-full mr-4; + &:after { + @apply top-1.5 left-full mr-4; + border-top: 1rem solid transparent; + border-bottom: 1rem solid transparent; + border-left: 1rem solid var(--primary-color); + } + } + + &.right { + @apply -top-3 left-full ml-4; + &:after { + @apply top-1.5 right-full ml-4; + border-top: 1rem solid transparent; + border-bottom: 1rem solid transparent; + border-right: 1rem solid var(--primary-color); + } + } + } + .markdown { @apply flex flex-col; @apply font-sans font-normal text-lg print:text-base; diff --git a/tailwind.config.js b/tailwind.config.js index c9e0236..6392a1f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,6 +5,7 @@ module.exports = { primary: { dark: "#22293D", DEFAULT: "#13304E", + light: "#1C4773", }, }, }, -- 2.45.2 From 50f9742c0867a550d03386b833ffddaf55a449f3 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:17:31 +0100 Subject: [PATCH 05/12] Fix footer nav links on smaller sizes --- src/components/layout/footer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/footer.rs b/src/components/layout/footer.rs index 0503fc1..aca7336 100644 --- a/src/components/layout/footer.rs +++ b/src/components/layout/footer.rs @@ -43,8 +43,8 @@ pub fn footer(_: &FooterProps) -> Html {
-
-
+
+
classes="hover:text-neutral-50" to={Route::Blog}> {"Latest Posts"} > -- 2.45.2 From 3926475fa91984fcd26f2dbc373e6e9eeea6f83d Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:17:55 +0100 Subject: [PATCH 06/12] Add saving of account details and fix loading on empty key --- src/model/trading/account.rs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/model/trading/account.rs b/src/model/trading/account.rs index c48c364..a4f8bf3 100644 --- a/src/model/trading/account.rs +++ b/src/model/trading/account.rs @@ -1,6 +1,6 @@ use std::rc::Rc; -use gloo::storage::Storage; +use gloo::storage::{errors::StorageError, Storage}; use serde::{Deserialize, Serialize}; use yew::Reducible; @@ -32,6 +32,7 @@ impl Default for Account { } pub enum AccountAction { + Load, SetPlaces { places: usize }, SetCurrency { currency: Currency }, SetExchangeRates { exchange_rates: ExchangeRates }, @@ -44,7 +45,9 @@ impl Reducible for Account { type Action = AccountAction; fn reduce(self: Rc, action: Self::Action) -> Rc { - match action { + let account = match action { + AccountAction::Load => Self::load(), + AccountAction::SetPlaces { places } => Self { places, ..(*self).clone() @@ -74,8 +77,10 @@ impl Reducible for Account { position_risk: risk, ..(*self).clone() }, - } - .into() + }; + + account.save(); + account.into() } } @@ -83,10 +88,17 @@ impl Account { pub fn load() -> Self { match gloo::storage::LocalStorage::get("trading.account") { Ok(stored) => stored, - Err(err) => { - log::error!("Failed to retrieve trading account from local storage: {err:?}"); - Self::default() - } + Err(err) => match err { + StorageError::KeyNotFound(_) => { + log::info!("No stored trading account found, using defaults"); + Self::default() + } + + _ => { + log::error!("Failed to retrieve trading account from local storage: {err:?}"); + Self::default() + } + }, } } -- 2.45.2 From 8e98956e69dcec954f7e038b38c71646d239bcd1 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:18:13 +0100 Subject: [PATCH 07/12] Add last of the position size calculator interface --- src/components/display.rs | 1 + src/components/display/tooltip.rs | 73 ++ src/pages/trading/position_size.rs | 1360 +++++++++++++++++++--------- 3 files changed, 1026 insertions(+), 408 deletions(-) create mode 100644 src/components/display/tooltip.rs diff --git a/src/components/display.rs b/src/components/display.rs index 7660853..fc40504 100644 --- a/src/components/display.rs +++ b/src/components/display.rs @@ -1,2 +1,3 @@ pub mod bar_chart; pub mod clipboard; +pub mod tooltip; diff --git a/src/components/display/tooltip.rs b/src/components/display/tooltip.rs new file mode 100644 index 0000000..711a7df --- /dev/null +++ b/src/components/display/tooltip.rs @@ -0,0 +1,73 @@ +use yew::{classes, function_component, html, use_state, Callback, Children, Html, Properties}; +use yew_icons::{Icon, IconId}; + +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum TooltipPosition { + Top, + Left, + Bottom, + Right, +} + +impl Default for TooltipPosition { + fn default() -> Self { + Self::Top + } +} + +#[derive(Properties, PartialEq)] +pub struct TooltipProps { + #[prop_or_default] + pub position: TooltipPosition, + #[prop_or_default] + pub children: Children, +} + +#[function_component(Tooltip)] +pub fn tooltip(props: &TooltipProps) -> Html { + let open = use_state(|| false); + + let onmouseover = { + let open = open.clone(); + Callback::from(move |_| { + open.set(true); + }) + }; + + let onmouseout = { + let open = open.clone(); + Callback::from(move |_| { + open.set(false); + }) + }; + + let popup = if *open { + html! { +
"top", + TooltipPosition::Left => "left", + TooltipPosition::Right => "right", + TooltipPosition::Bottom => "bottom" + } + )}> +
+ {props.children.clone()} +
+
+ } + } else { + html! {} + }; + + html! { +
+ + {popup} +
+ } +} diff --git a/src/pages/trading/position_size.rs b/src/pages/trading/position_size.rs index 96c5116..28190b2 100644 --- a/src/pages/trading/position_size.rs +++ b/src/pages/trading/position_size.rs @@ -1,22 +1,28 @@ use web_sys::HtmlSelectElement; use yew::{ - function_component, html, use_context, use_effect, use_effect_with_deps, use_reducer, Callback, - Children, ContextProvider, Html, Properties, TargetCast, UseReducerHandle, + classes, function_component, html, use_context, use_effect_with_deps, use_reducer, AttrValue, + Callback, Children, Classes, ContextProvider, Html, Properties, TargetCast, UseReducerHandle, }; use yew_hooks::{use_async, UseAsyncHandle}; use crate::{ - components::fields::{ - currency::CurrencySelect, - label::Label, - number::{format_number, Number}, - toggle::Toggle, + components::{ + display::tooltip::{Tooltip, TooltipPosition}, + fields::{ + currency::CurrencySelect, + label::Label, + number::{format_number, Number}, + toggle::Toggle, + }, }, model::{ - currency::get_exchange_rates, + currency::{get_exchange_rates, Currency}, trading::{ account::{Account, AccountAction}, - position::{Direction, Position, PositionAction, PositionSize, StopLossQuantity}, + position::{ + ActualStopLoss, Direction, Position, PositionAction, PositionSize, StopLoss, + StopLossQuantity, + }, }, }, }; @@ -24,20 +30,128 @@ use crate::{ type AccountHandle = UseReducerHandle; type PositionHandle = UseReducerHandle; +#[derive(Properties, PartialEq)] +struct PanelProps { + pub title: AttrValue, + #[prop_or_default] + pub skip: bool, + #[prop_or_default] + pub class: Classes, + #[prop_or_default] + pub children: Children, +} + +#[function_component(Panel)] +fn panel(props: &PanelProps) -> Html { + let start = if props.skip { "md:col-start-2" } else { "" }; + + html! { +
+

{props.title.clone()}

+
+ {props.children.clone()} +
+
+ } +} + +#[derive(Properties, PartialEq)] +struct TableProps { + #[prop_or_default] + pub class: Classes, + #[prop_or_default] + pub children: Children, +} + +#[function_component(Table)] +fn table(props: &TableProps) -> Html { + html! { + + + {props.children.clone()} + +
+ } +} + +#[derive(Properties, PartialEq)] +struct TableRowProps { + #[prop_or_default] + pub title: Option, + #[prop_or_default] + pub error: bool, + pub value: f64, + #[prop_or_default] + pub places: usize, + #[prop_or_default] + pub currency: Option, + #[prop_or_default] + pub suffix: Option, + #[prop_or_default] + pub tooltip_position: TooltipPosition, + #[prop_or_default] + pub children: Children, +} + +#[function_component(TableRow)] +fn table_row(props: &TableRowProps) -> Html { + let title = if let Some(title) = &props.title { + html! { + {title} + } + } else { + html! { + + } + }; + + let number = format_number( + props.value, + true, + props.places, + props.currency.as_ref().map(Currency::symbol), + props.suffix.as_deref(), + ) + .expect("format_number"); + + let tooltip = if props.children.is_empty() { + html! {} + } else { + html! { + + {props.children.clone()} + + } + }; + + html! { + + {title} + + {number} + {tooltip} + + + } +} + #[function_component(AccountInfo)] fn account_info() -> Html { let account = use_context::().expect("AccountHandle"); + let position = use_context::().expect("PositionHandle"); let currency_change = { let account = account.clone(); - Callback::from(move |currency| account.dispatch(AccountAction::SetCurrency { currency })) + Callback::from(move |currency| { + account.dispatch(AccountAction::SetCurrency { currency }); + }) }; let amount_change = { let account = account.clone(); Callback::from(move |amount| { if let Some(amount) = amount { - account.dispatch(AccountAction::SetAmount { amount }) + account.dispatch(AccountAction::SetAmount { amount }); } }) }; @@ -46,7 +160,7 @@ fn account_info() -> Html { let account = account.clone(); Callback::from(move |risk| { if let Some(risk) = risk { - account.dispatch(AccountAction::SetMarginRisk { risk: risk / 100.0 }) + account.dispatch(AccountAction::SetMarginRisk { risk: risk / 100.0 }); } }) }; @@ -55,7 +169,7 @@ fn account_info() -> Html { let account = account.clone(); Callback::from(move |risk| { if let Some(risk) = risk { - account.dispatch(AccountAction::SetPositionRisk { risk: risk / 100.0 }) + account.dispatch(AccountAction::SetPositionRisk { risk: risk / 100.0 }); } }) }; @@ -65,51 +179,108 @@ fn account_info() -> Html { Callback::from(move |places| { if let Some(places) = places { let places = places as usize; - account.dispatch(AccountAction::SetPlaces { places }) + account.dispatch(AccountAction::SetPlaces { places }); } }) }; + let pc = account.currency != position.position_currency; + let qc = position.position_currency != position.quote_currency; + html! { -
-

{"Account Information"}

-
- - - - - -
-
+ + + + + + + if pc || qc { +
+ + + + + + + + + if pc { + + + + + } + if qc { + + + + + } + +
{"Currency Pair"}{"Exchange Rate"}
+ {format!("{}{}", + account.currency, + position.position_currency)} + + {format_number( + account + .exchange_rates + .rates + .get(&position.position_currency) + .copied() + .unwrap_or(0.0), + false, + account.places, + Some(position.position_currency.symbol()), + None + ) + .expect("format_number")} +
+ {format!("{}{}", + position.position_currency, + position.quote_currency)} + + {format_number( + position.conversion, + false, + account.places, + Some(position.quote_currency.symbol()), + None + ) + .expect("format_number")} +
+
+ } +
} } @@ -314,47 +485,6 @@ fn position_info() -> Html { }, }; - let position_exchange = if account.currency != position.position_currency { - format!( - "Position ({}→{} {})", - account.currency, - position.position_currency, - format_number( - account - .exchange_rates - .rates - .get(&position.position_currency) - .copied() - .unwrap_or(0.0), - false, - account.places, - Some(position.position_currency.symbol()), - None - ) - .expect("format_number") - ) - } else { - "Position".to_string() - }; - - let quote_exchange = if position.quote_currency != position.position_currency { - format!( - "Quote ({}→{} {})", - position.position_currency, - position.quote_currency, - format_number( - position.conversion, - false, - account.places, - Some(position.quote_currency.symbol()), - None - ) - .expect("format_number") - ) - } else { - "Quote".to_string() - }; - let position_margin = if position.margin != 0.0 { format!("Position Margin ({:.0}x leverage)", 1.0 / position.margin) } else { @@ -362,131 +492,128 @@ fn position_info() -> Html { }; html! { -
-

{"Position Information"}

-
-
- - -
-
+ + + } } @@ -506,215 +633,625 @@ fn report_position_size() -> Html { actual, } = PositionSize::compute(&account, &position); - let ac = account.currency.symbol(); - let pc = position.position_currency.symbol(); let qc = position.quote_currency.symbol(); let ap = account.currency != position.position_currency; let pq = position.position_currency != position.quote_currency; + let margin_fmt = + format_number(position.margin * 100.0, true, 2, None, Some("%")).expect("format_number"); + let quantity_fmt = format_number( + position.quantity.unwrap_or_default(), + true, + 2, + None, + Some(" units"), + ) + .expect("format_number"); + let open_price_fmt = format_number(position.open_price, true, account.places, Some(qc), None) + .expect("format_number"); + let actual = if let Some(actual) = actual { + let excess_risk = (actual.margin * 100.0).round() > account.margin_risk * 100.0; + html! { -
- - - - - - - - - - - - - - if pq { - - - - } - if ap { - - - - } - -
{"Actual Quantity"}
- {"Actual cost of opening a position of "} - {format_number( - position.quantity.unwrap_or_default(), - true, 2, None, None - ).expect("format_number")} - {" units at "} - {format_number( - position.open_price, true, - account.places, Some(qc), None - ).expect("format_number")} - - {format_number( - position.quantity.unwrap_or_default() * position.open_price, - true, account.places, Some(qc), None - ).expect("format_number")} -
- {"Amount of margin required at "} - {format_number( - position.margin * 100.0, true, - 2, None, Some("%") - ).expect("format_number")} - {format!(" position margin ({:.0}x leverage)", - 1.0 / if position.margin == 0.0 { - 1.0 - } else { - position.margin - }, - )} - - {format_number( - actual.cost_quote, true, - account.places, Some(qc), None - ).expect("format_number")} -
- - {format_number( - actual.cost_position, true, - account.places, Some(pc), None - ).expect("format_number")} -
- - {format_number( - actual.cost, true, - account.places, Some(ac), None - ).expect("format_number")} -
-
+ <> + + {"Quantity entered into the position form."} + + + {"Actual cost of opening a position of "} + {&quantity_fmt} + {" units at "} + {&open_price_fmt} + + + {"Amount required at "} + {&margin_fmt} + {format!(" position margin ({:.0}x leverage)", + 1.0 / if position.margin == 0.0 { + 1.0 + } else { + position.margin + }, + )} + + + if pq { + + {"Amount required at "} + {format_number( + position.margin * 100.0, true, + 2, None, Some("%") + ).expect("format_number")} + {" margin, converted into the position currency."} + + } + if ap { + + {"Amount required at "} + {&margin_fmt} + {" margin, converted into the account currency."} + + } + + + {"The percentage of the account that will be committed as margin to open the position."} + + if excess_risk { + + + {"Actual quantity of "} + {&quantity_fmt} + {" exceeds account margin risk of "} + {format_number( + account.margin_risk * 100.0, true, + 2, None, Some("%") + ).expect("format_number")} + {" by "} + {format_number( + actual.cost - available, true, + account.places, Some(account.currency.symbol()), None + ).expect("format_number")} + {"."} + + + } + } } else { html! {} }; html! { -
-

{"Position Size Information"}

-
- - - - - - - - - - if ap { - - - - - } - if pq { - - - - - } - -
{"Margin Risk"}
- {"Amount of account available under margin risk"} - - {format_number(available, true, - account.places, Some(ac), None - ).expect("format_number")} -
- {"Available account under margin risk in the position currency"} - - {format_number( - available_position, true, - account.places, Some(pc), None - ).expect("format_number")} -
- {"Available account under margin risk in the quote currency"} - - {format_number( - available_quote, true, - account.places, Some(qc), None - ).expect("format_number")} -
-
+ + + + {"Amount of account available under margin risk in the account currency."} + -
-
- - - - - - - - - if ap { - - - - - } - if pq { - - - - - } - - - - - -
{"Position Margin and Amount"}
- {"Available amount with a "} - {format_number( - position.margin * 100.0, - true, 2, None, Some("%") - ).expect("format_number")} - {" position margin"} - - {format_number( - margin, true, - account.places, Some(ac), None - ).expect("format_number")} -
- {"Available amount under position margin in position currency"} - - {format_number( - margin_position, true, - account.places, Some(pc), None - ).expect("format_number")} -
- {"Available amount under position margin in quote currency"} - - {format_number( - margin_quote, true, - account.places, Some(qc), None - ).expect("format_number")} -
- {"Position size available at open price of "} - {format_number( - position.open_price, true, - account.places, Some(qc), None - ).expect("format_number")} - {" with margin of "} - {format_number( - margin_quote, true, - account.places, Some(qc), None - ).expect("format_number")} - - {format_number( - affordable, true, - 2, None, Some(" units") - ).expect("format_number")} -
-
+ if ap { + + {"Amount of account available under margin risk in the position currency"} + + } + if pq { + + {"Amount of account available under margin risk in the quote currency"} + + } - {actual} -
+ + {"Available amount with a "} + {&margin_fmt} + {" position margin."} + + if ap { + + {"Available amount with a "} + {&margin_fmt} + {" position margin converted to position currency."} + + } + if pq { + + {"Available amount with a "} + {&margin_fmt} + {" position margin converted to quote currency."} + + } + + + {"Position size that can be taken at an open price of "} + {&open_price_fmt} + {" with available margin of "} + {format_number( + margin_quote, true, + account.places, Some(qc), None + ).expect("format_number")} + + {actual} + + + } +} + +#[function_component(ReportStopLoss)] +fn report_stop_loss() -> Html { + let account = use_context::().expect("AccountHandle"); + let position = use_context::().expect("PositionHandle"); + + let ac = account.currency.symbol(); + let qc = position.quote_currency.symbol(); + let ap = account.currency != position.position_currency; + let pq = position.position_currency != position.quote_currency; + + let position_risk_fmt = format_number(account.position_risk * 100.0, true, 2, None, Some("%")) + .expect("format_number"); + let quantity_fmt = format_number( + position.quantity.unwrap_or_default(), + true, + 2, + None, + Some(" units"), + ) + .expect("format_number"); + let open_price_fmt = format_number(position.open_price, true, account.places, Some(qc), None) + .expect("format_number"); + + let quantity = position.quantity.unwrap_or_else(|| { + let PositionSize { affordable, .. } = PositionSize::compute(&account, &position); + affordable + }); + + let StopLoss { + available, + available_position, + available_quote, + distance, + actual, + .. + } = StopLoss::compute(&account, &position, quantity); + + let actual = if let Some(ActualStopLoss { + distance, + loss, + risk, + }) = actual + { + let excess_risk = (risk * 100.0).round() > account.position_risk * 100.0; + let stop_loss_fmt = format_number( + position.stop_loss.unwrap_or_default(), + true, + account.places, + Some(qc), + None, + ) + .expect("format_number"); + + html! { + <> + + + {"The distance provided in the position form."} + + + {"The actual account loss that will be incurred should the "} + {"position close at the provided stop loss position of "} + {&stop_loss_fmt} + {"."} + + + {"Percentage of account at risk for the provided stop loss position of "} + {&stop_loss_fmt} + {"."} + + if excess_risk { + + + {"Actual stop loss of "} + {&stop_loss_fmt} + {" exceeds account position risk of "} + {&position_risk_fmt} + {" by "} + {format_number( + loss - available, true, + account.places, Some(ac), None + ).expect("format_number")} + + + } + + } + } else { + html! {} + }; + + html! { + + + + {"Amount of account available under position risk of "} + {&position_risk_fmt} + {"."} + + if ap { + + {"Amount of account available under position risk of "} + {&position_risk_fmt} + {" in the position currency."} + + } + if pq { + + {"Amount of account available under position risk of "} + {&position_risk_fmt} + {" in the quote currency."} + + } + + + {"The maximum stop loss distance for a position of "} + {&quantity_fmt} + {" at "} + {&open_price_fmt} + {" to remain within the position risk of "} + {&position_risk_fmt} + {" of the account."} + + + position.open_price - distance, + Direction::Sell => position.open_price + distance + }} + places={account.places} + currency={position.position_currency}> + {"The maximum stop loss for a position of "} + {&quantity_fmt} + {" at "} + {&open_price_fmt} + {" to remain within the position risk of "} + {&position_risk_fmt} + {" of the account."} + + {actual} +
+

+ {"This panel shows the maximum available stop loss, given the "} + {position.quantity.map(|_| "specified").unwrap_or("calculated")} + {" position size of "} + {&quantity_fmt} + {", and the account position risk."} +

+
+ } +} + +#[function_component(ReportPlannedStopLoss)] +fn report_planned_stop_loss() -> Html { + let account = use_context::().expect("AccountHandle"); + let position = use_context::().expect("PositionHandle"); + + let Some(_) = position.stop_loss else { + return html! {} + }; + + let qc = position.quote_currency.symbol(); + let ap = account.currency != position.position_currency; + let pq = position.position_currency != position.quote_currency; + + let position_risk_fmt = format_number(account.position_risk * 100.0, true, 2, None, Some("%")) + .expect("format_number"); + let margin_fmt = + format_number(position.margin * 100.0, true, 2, None, Some("%")).expect("format_number"); + let leverage_fmt = format!(" ({:.0}x leverage).", 1.0 / position.margin); + let quantity_fmt = format_number( + position.quantity.unwrap_or_default(), + true, + 2, + None, + Some(" units"), + ) + .expect("format_number"); + let open_price_fmt = format_number(position.open_price, true, account.places, Some(qc), None) + .expect("format_number"); + + let StopLossQuantity { + available, + available_position, + available_quote, + distance, + affordable, + margin, + } = StopLossQuantity::compute(&account, &position); + + let margin = if distance != 0.0 { + html! { + <> + + {"The amount of account margin that will be committted to "} + {"open a position of "} + {&quantity_fmt} + {" at "} + {&open_price_fmt} + {" with a position margin of "} + {&margin_fmt} + {&leverage_fmt} + + + + {"The amount of account margin, as a percentage of the account "} + {"value, that will be committed to opening a position of "} + {&quantity_fmt} + {" at "} + {&open_price_fmt} + {" with a position margin of "} + {&margin_fmt} + {&leverage_fmt} + + + } + } else { + html! {} + }; + + html! { + + + + {"Amount of account available under position risk of "} + {&position_risk_fmt} + {"."} + + if ap { + + {"Amount of account available under position risk of "} + {&position_risk_fmt} + {"in the position currency."} + + } + if pq { + + {"Amount of account available under position risk of "} + {&position_risk_fmt} + {"in the quote currency."} + + } + + + {"Stop loss entered in the position form."} + + + {"Stop loss distance entered into the position form."} + + + {"The position size that can be taken at an open price of "} + {format_number( + position.open_price, true, + account.places, Some(qc), None + ).expect("format_number")} + {", given an account position risk of "} + {&position_risk_fmt} + + {margin} +
+

+ {"This pannel shows the maximum position size available, given "} + {"the entered position stop loss and the account position risk."} +

+
+ } +} + +#[function_component(ReportTakeProfit)] +fn report_take_profit() -> Html { + let account = use_context::().expect("AccountHandle"); + let position = use_context::().expect("PositionHandle"); + + let Some(tp) = position.take_profit else { + return html! {} + }; + + let pc = position.position_currency.symbol(); + + let tp_fmt = format_number(tp, true, account.places, Some(pc), None).expect("format_number"); + let quantity_fmt = format_number( + position.quantity.unwrap_or_default(), + true, + 2, + None, + Some(" units"), + ) + .expect("format_number"); + + let tp_distance = match position.direction { + Direction::Buy => tp - position.open_price, + Direction::Sell => position.open_price - tp, + }; + + let sl_ratio = if let Some(sl) = position.stop_loss { + tp_distance + / match position.direction { + Direction::Buy => position.open_price - sl, + Direction::Sell => sl - position.open_price, + } + } else { + 0.0 + }; + + let realized = (tp - position.open_price) * position.quantity.unwrap_or_default(); + let realized_account = realized + / (position.conversion + * account + .exchange_rates + .rates + .get(&position.position_currency) + .copied() + .unwrap_or(1.0)); + + html! { + + + + {"Take profit entered in the position form."} + + + {"Take profit distance, based on take profit entered in the position form."} + + if position.stop_loss.is_some() { + + {"The ratio of the take profit distance to the stop loss distance"} + + if sl_ratio < 2.0 { + + + + } + } + + + {"Total realized profit if closing a position of "} + {&quantity_fmt} + {" at "} + {&tp_fmt} + + + {"Total realized account profit if closing a position of "} + {&quantity_fmt} + {" at "} + {&tp_fmt} + +
+ {"A profit/loss ratio of "} + {format!("{:.0}%", sl_ratio * 100.0)} + {" is below the recommended minimum of 2:1."} +
+
} } @@ -739,12 +1276,16 @@ fn account_provider(props: &AccountProviderProps) -> Html { }) }; - use_effect_with_deps( - move |_| { - get_exchange_rates.run(); - }, - account.currency, - ); + { + let account_inner = account.clone(); + use_effect_with_deps( + move |_| { + account_inner.dispatch(AccountAction::Load); + get_exchange_rates.run(); + }, + account.currency, + ); + } html! { context={account}> @@ -799,13 +1340,16 @@ pub fn page() -> Html { html! { -
-
+
+
-
+
+ + +
-- 2.45.2 From d15e155dda26e20bb5cffc8e6118320491e0c11d Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:34:53 +0100 Subject: [PATCH 08/12] Simplify some of the modules --- src/components.rs | 13 +++++++++++-- src/components/blog.rs | 2 -- src/components/display.rs | 3 --- 3 files changed, 11 insertions(+), 7 deletions(-) delete mode 100644 src/components/blog.rs delete mode 100644 src/components/display.rs diff --git a/src/components.rs b/src/components.rs index a1629f5..ce045d2 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,13 +1,22 @@ pub mod analytics; -pub mod blog; pub mod content; -pub mod display; pub mod head; pub mod layout; pub mod render; pub mod seo; pub mod title; +pub mod blog { + pub mod post_card; + pub mod post_card_list; +} + +pub mod display { + pub mod bar_chart; + pub mod clipboard; + pub mod tooltip; +} + pub mod fields { pub mod currency; pub mod label; diff --git a/src/components/blog.rs b/src/components/blog.rs deleted file mode 100644 index f7026e6..0000000 --- a/src/components/blog.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod post_card; -pub mod post_card_list; diff --git a/src/components/display.rs b/src/components/display.rs deleted file mode 100644 index fc40504..0000000 --- a/src/components/display.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod bar_chart; -pub mod clipboard; -pub mod tooltip; -- 2.45.2 From bb9af92d21ceb3160b452bb7c97b628b5d83b6ad Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:35:01 +0100 Subject: [PATCH 09/12] Add more release flags --- Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c2f0d5c..22e6221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,10 @@ edition = "2021" publish = false [profile.release] -lto = true +panic = "abort" +codegen-units = 1 opt-level = 'z' +lto = true [features] hydration = [ -- 2.45.2 From 42dcedd3429c55255babf596d6425c530d09799f Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:42:29 +0100 Subject: [PATCH 10/12] Add `` component and use to stop rendering of calculator --- src/components.rs | 1 + src/components/display/client_only.rs | 29 +++++++++++++++++++++++++++ src/pages/trading/position_size.rs | 29 ++++++++++++++++----------- 3 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 src/components/display/client_only.rs diff --git a/src/components.rs b/src/components.rs index ce045d2..7cfc4f8 100644 --- a/src/components.rs +++ b/src/components.rs @@ -13,6 +13,7 @@ pub mod blog { pub mod display { pub mod bar_chart; + pub mod client_only; pub mod clipboard; pub mod tooltip; } diff --git a/src/components/display/client_only.rs b/src/components/display/client_only.rs new file mode 100644 index 0000000..c0ce65f --- /dev/null +++ b/src/components/display/client_only.rs @@ -0,0 +1,29 @@ +use yew::{function_component, html, use_state, Children, Html, Properties}; +use yew_hooks::use_effect_once; + +#[derive(Properties, PartialEq)] +pub struct ClientOnlyProps { + #[prop_or_default] + pub children: Children, +} + +#[function_component(ClientOnly)] +pub fn client_only(props: &ClientOnlyProps) -> Html { + let loaded = use_state(|| false); + + { + let loaded = loaded.clone(); + use_effect_once(move || { + loaded.set(true); + || {} + }) + } + + if !*loaded { + html! {} + } else { + html! { + <>{props.children.clone()} + } + } +} diff --git a/src/pages/trading/position_size.rs b/src/pages/trading/position_size.rs index 28190b2..b428257 100644 --- a/src/pages/trading/position_size.rs +++ b/src/pages/trading/position_size.rs @@ -7,7 +7,10 @@ use yew_hooks::{use_async, UseAsyncHandle}; use crate::{ components::{ - display::tooltip::{Tooltip, TooltipPosition}, + display::{ + client_only::ClientOnly, + tooltip::{Tooltip, TooltipPosition}, + }, fields::{ currency::CurrencySelect, label::Label, @@ -1340,18 +1343,20 @@ pub fn page() -> Html { html! { -
-
- - + +
+
+ + +
+
+ + + + +
-
- - - - -
-
+ } -- 2.45.2 From e622f47db91ce8f610a2cb5f594da4185b2880e7 Mon Sep 17 00:00:00 2001 From: Blake Rain Date: Mon, 25 Sep 2023 17:46:15 +0100 Subject: [PATCH 11/12] Add metadata to position size calculator --- src/pages/trading/position_size.rs | 46 +++++++++++++++++++----------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/pages/trading/position_size.rs b/src/pages/trading/position_size.rs index b428257..f263f9f 100644 --- a/src/pages/trading/position_size.rs +++ b/src/pages/trading/position_size.rs @@ -17,6 +17,8 @@ use crate::{ number::{format_number, Number}, toggle::Toggle, }, + seo::WebPageSeo, + title::Title, }, model::{ currency::{get_exchange_rates, Currency}, @@ -28,6 +30,7 @@ use crate::{ }, }, }, + pages::Route, }; type AccountHandle = UseReducerHandle; @@ -1341,23 +1344,32 @@ fn position_provider(props: &PositionProviderProps) -> Html { #[function_component(Page)] pub fn page() -> Html { html! { - - - -
-
- - + <> + + <WebPageSeo + route={Route::PositionSize} + title={"Position Size Calculator"} + excerpt={Some("A tool to help you calculate the size of a position given account risk limits")} + index={true} + follow={true} /> + <AccountProvider> + <PositionProvider> + <ClientOnly> + <div class="container mx-auto my-8"> + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> + <AccountInfo /> + <PositionInfo /> + </div> + <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8"> + <ReportPositionSize /> + <ReportStopLoss /> + <ReportTakeProfit /> + <ReportPlannedStopLoss /> + </div> </div> - <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8"> - <ReportPositionSize /> - <ReportStopLoss /> - <ReportTakeProfit /> - <ReportPlannedStopLoss /> - </div> - </div> - </ClientOnly> - </PositionProvider> - </AccountProvider> + </ClientOnly> + </PositionProvider> + </AccountProvider> + </> } } -- 2.45.2 From 5648434634a5de07112d7a5ece953f4eea0dbea3 Mon Sep 17 00:00:00 2001 From: Blake Rain <blake.rain@blakerain.com> Date: Mon, 25 Sep 2023 17:47:36 +0100 Subject: [PATCH 12/12] Add metadata to analytics dashboard --- src/pages/analytics/dashboard.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pages/analytics/dashboard.rs b/src/pages/analytics/dashboard.rs index d8d55f2..5173fd9 100644 --- a/src/pages/analytics/dashboard.rs +++ b/src/pages/analytics/dashboard.rs @@ -9,7 +9,8 @@ use crate::{ api::{get_month_views, PageViewsMonth, PageViewsMonthResult}, auth::{AuthTokenContext, WithAuth}, }, - components::display::bar_chart::BarChart, + components::{display::bar_chart::BarChart, seo::WebPageSeo, title::Title}, + pages::Route, }; fn month_view_chart( @@ -242,8 +243,17 @@ fn dashboard_content() -> Html { #[function_component(Page)] pub fn page() -> Html { html! { - <WithAuth> - <DashboardContent /> - </WithAuth> + <> + <Title title={"Analytics Dashboard"} /> + <WebPageSeo + route={Route::Analytics} + title={"Analytics Dashboard"} + excerpt={Some("Analytics Dashboard")} + index={false} + follow={false} /> + <WithAuth> + <DashboardContent /> + </WithAuth> + </> } } -- 2.45.2