Initial rebuild of position size calculator #37
@ -77,6 +77,8 @@ features = [
|
||||
"LucideBug",
|
||||
"LucideCheck",
|
||||
"LucideCheckCircle",
|
||||
"LucideClipboardCheck",
|
||||
"LucideClipboardCopy",
|
||||
"LucideFlame",
|
||||
"LucideLink",
|
||||
"LucideList",
|
||||
|
15
public/format.js
Normal file
15
public/format.js
Normal file
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -1 +1,2 @@
|
||||
pub mod bar_chart;
|
||||
pub mod clipboard;
|
||||
|
51
src/components/display/clipboard.rs
Normal file
51
src/components/display/clipboard.rs
Normal file
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<button type="button" title="Copy to clipboard" {onclick}>
|
||||
<Icon
|
||||
icon_id={
|
||||
if *copied {
|
||||
IconId::LucideClipboardCheck
|
||||
} else {
|
||||
IconId::LucideClipboardCopy
|
||||
}
|
||||
}
|
||||
width={"1em".to_string()}
|
||||
height={"1em".to_string()} />
|
||||
</button>
|
||||
}
|
||||
}
|
40
src/components/fields/currency.rs
Normal file
40
src/components/fields/currency.rs
Normal file
@ -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<Currency>,
|
||||
}
|
||||
|
||||
#[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::<HtmlSelectElement>()
|
||||
.expect("target")
|
||||
.value()
|
||||
.parse::<Currency>()
|
||||
.expect("currency"),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<select {onchange}>
|
||||
{
|
||||
for CURRENCIES.iter().map(|currency| html! {
|
||||
<option value={currency.to_string()} selected={*currency == props.value}>
|
||||
{currency.to_string()}
|
||||
</option>
|
||||
})
|
||||
}
|
||||
</select>
|
||||
}
|
||||
}
|
24
src/components/fields/label.rs
Normal file
24
src/components/fields/label.rs
Normal file
@ -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! {
|
||||
<div class={class}>
|
||||
<label class="text-sm font-medium">
|
||||
{props.title.clone()}
|
||||
</label>
|
||||
{props.children.clone()}
|
||||
</div>
|
||||
}
|
||||
}
|
237
src/components/fields/number.rs
Normal file
237
src/components/fields/number.rs
Normal file
@ -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<String, JsValue>;
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct NumberProps {
|
||||
pub icon_left: Option<IconId>,
|
||||
pub icon_right: Option<IconId>,
|
||||
|
||||
pub icon_left_class: Option<Classes>,
|
||||
pub icon_right_class: Option<Classes>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub class: Classes,
|
||||
pub id: Option<AttrValue>,
|
||||
pub name: Option<AttrValue>,
|
||||
pub placeholder: Option<AttrValue>,
|
||||
|
||||
#[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<AttrValue>,
|
||||
pub suffix: Option<AttrValue>,
|
||||
|
||||
pub onfocus: Option<Callback<FocusEvent>>,
|
||||
pub onblur: Option<Callback<FocusEvent>>,
|
||||
pub onchange: Option<Callback<Option<f64>>>,
|
||||
pub oninput: Option<Callback<Option<f64>>>,
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<Icon
|
||||
icon_id={icon}
|
||||
class={classes!("group-focus-within:text-indigo-300",
|
||||
props.icon_left_class.clone())} />
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let icon_right = if let Some(icon) = props.icon_right {
|
||||
classes.push("icon-right");
|
||||
Some(html! {
|
||||
<Icon
|
||||
icon_id={icon}
|
||||
class={classes!("group-focus-within:text-indigo-300",
|
||||
props.icon_right_class.clone())} />
|
||||
})
|
||||
} 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 <Enter>). 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 <input> 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::<web_sys::HtmlInputElement>()
|
||||
.expect("<input> 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::<f64>() {
|
||||
// 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::<web_sys::HtmlInputElement>() else {
|
||||
log::error!("No <input> element found in event");
|
||||
return;
|
||||
};
|
||||
|
||||
let value = el.value();
|
||||
input_value.set(value.clone());
|
||||
|
||||
if let Some(oninput) = &oninput {
|
||||
oninput.emit(value.parse::<f64>().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! {
|
||||
<input
|
||||
ref={input_ref}
|
||||
class={classes}
|
||||
type="text"
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
disabled={props.disabled}
|
||||
value={rendered}
|
||||
{onfocus}
|
||||
{onblur}
|
||||
{onchange}
|
||||
{oninput} />
|
||||
};
|
||||
|
||||
if icon_left.is_some() || icon_right.is_some() {
|
||||
html! {
|
||||
<div class="input-icons group">
|
||||
{icon_left}
|
||||
{input}
|
||||
{icon_right}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
input
|
||||
}
|
||||
}
|
48
src/components/fields/toggle.rs
Normal file
48
src/components/fields/toggle.rs
Normal file
@ -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<bool>,
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class={classes!("toggle",
|
||||
if props.value { "active" } else { "inactive" },
|
||||
if props.disabled { "disabled" } else { "" },
|
||||
props.classes.clone())}
|
||||
{onclick}>
|
||||
<div class="toggle-background" />
|
||||
<div class="toggle-inner" />
|
||||
</div>
|
||||
if !props.label.is_empty() {
|
||||
<div>
|
||||
{props.label.clone()}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
@ -65,6 +65,9 @@ pub fn footer(_: &FooterProps) -> Html {
|
||||
rel="noreferrer">
|
||||
{"Mastodon"}
|
||||
</a>
|
||||
<Link<Route> classes="hover:text-neutral-50" to={Route::PositionSize}>
|
||||
{"Position Size Calculator"}
|
||||
</Link<Route>>
|
||||
<Link<Route> classes="hover:text-neutral-50" to={Route::Analytics}>
|
||||
{"Analytics"}
|
||||
</Link<Route>>
|
||||
|
@ -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]
|
||||
|
149
src/model/currency.rs
Normal file
149
src/model/currency.rs
Normal file
@ -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<Self, Self::Err> {
|
||||
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<Currency, f64>,
|
||||
}
|
||||
|
||||
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<String, f64>,
|
||||
}
|
||||
|
||||
pub async fn get_exchange_rates(
|
||||
base: Currency,
|
||||
target: Option<Currency>,
|
||||
) -> Result<ExchangeRates, &'static str> {
|
||||
let symbols = target.as_ref().map(Currency::to_string).unwrap_or_else(|| {
|
||||
CURRENCIES
|
||||
.iter()
|
||||
.map(Currency::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.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::<ExchangeRateResult>()
|
||||
.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 })
|
||||
}
|
98
src/model/trading/account.rs
Normal file
98
src/model/trading/account.rs
Normal file
@ -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<Self>, action: Self::Action) -> Rc<Self> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
364
src/model/trading/position.rs
Normal file
364
src/model/trading/position.rs
Normal file
@ -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<Self, Self::Err> {
|
||||
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<f64>,
|
||||
pub direction: Direction,
|
||||
pub margin: f64,
|
||||
pub take_profit: Option<f64>,
|
||||
pub stop_loss: Option<f64>,
|
||||
}
|
||||
|
||||
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<f64> },
|
||||
SetDirection { direction: Direction },
|
||||
SetMargin { margin: f64 },
|
||||
SetTakeProfit { price: Option<f64> },
|
||||
SetStopLoss { price: Option<f64> },
|
||||
}
|
||||
|
||||
impl Reducible for Position {
|
||||
type Action = PositionAction;
|
||||
|
||||
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
|
||||
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<ActualPositionSize>,
|
||||
}
|
||||
|
||||
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<ActualStopLoss>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
@ -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! { <disclaimer::Page /> },
|
||||
Self::NotFound => html! { <not_found::Page /> },
|
||||
Self::Analytics => html! { <analytics::dashboard::Page /> },
|
||||
Self::PositionSize => html! { <trading::position_size::Page /> },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
814
src/pages/trading/position_size.rs
Normal file
814
src/pages/trading/position_size.rs
Normal file
@ -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<Account>;
|
||||
type PositionHandle = UseReducerHandle<Position>;
|
||||
|
||||
#[function_component(AccountInfo)]
|
||||
fn account_info() -> Html {
|
||||
let account = use_context::<AccountHandle>().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! {
|
||||
<div class="flex flex-col gap-8 border border-primary rounded-md p-4">
|
||||
<h1 class="text-2xl font-semibold">{"Account Information"}</h1>
|
||||
<div class="grid grid-cols-2 items-center gap-4">
|
||||
<Label title="Account Currency">
|
||||
<CurrencySelect
|
||||
value={account.currency}
|
||||
onchange={currency_change} />
|
||||
</Label>
|
||||
<Label title="Account Value">
|
||||
<Number
|
||||
thousands={true}
|
||||
prefix={account.currency.symbol()}
|
||||
places={account.places}
|
||||
value={account.amount}
|
||||
oninput={amount_change} />
|
||||
</Label>
|
||||
<Label title="Margin Risk">
|
||||
<Number
|
||||
value={account.margin_risk * 100.0}
|
||||
places={0}
|
||||
suffix="%"
|
||||
onchange={margin_risk_change} />
|
||||
</Label>
|
||||
<Label title="Position Risk">
|
||||
<Number
|
||||
value={account.position_risk * 100.0}
|
||||
places={0}
|
||||
suffix="%"
|
||||
onchange={position_risk_change} />
|
||||
</Label>
|
||||
<Label title="Decimal Places">
|
||||
<Number
|
||||
value={account.places as f64}
|
||||
places={0}
|
||||
suffix=" digits"
|
||||
onchange={places_change} />
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(PositionInfo)]
|
||||
fn position_info() -> Html {
|
||||
let account = use_context::<AccountHandle>().expect("AccountHandle");
|
||||
let position = use_context::<PositionHandle>().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::<HtmlSelectElement>()
|
||||
.expect("target")
|
||||
.value()
|
||||
.parse::<Direction>()
|
||||
.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! {
|
||||
<div class="flex flex-col gap-8 border border-primary rounded-md p-4">
|
||||
<h1 class="text-2xl font-semibold">{"Position Information"}</h1>
|
||||
<div class="grid grid-cols-2 items-center gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Label title={position_exchange}>
|
||||
<CurrencySelect
|
||||
value={position.position_currency}
|
||||
onchange={position_currency_change} />
|
||||
</Label>
|
||||
<Label title={quote_exchange}>
|
||||
<CurrencySelect
|
||||
value={position.quote_currency}
|
||||
onchange={quote_currency_change} />
|
||||
</Label>
|
||||
</div>
|
||||
<Label title={position_margin}>
|
||||
<Number
|
||||
value={position.margin * 100.0}
|
||||
places={2}
|
||||
suffix="%"
|
||||
oninput={margin_change} />
|
||||
</Label>
|
||||
<Label title="Position Direction">
|
||||
<select onchange={direction_change}>
|
||||
<option
|
||||
value={Direction::Buy.to_string()}
|
||||
selected={position.direction == Direction::Buy}>
|
||||
{"Buy"}
|
||||
</option>
|
||||
<option
|
||||
value={Direction::Sell.to_string()}
|
||||
selected={position.direction == Direction::Sell}>
|
||||
{"Sell"}
|
||||
</option>
|
||||
</select>
|
||||
</Label>
|
||||
<Label title="Open Price">
|
||||
<Number
|
||||
value={position.open_price}
|
||||
thousands={true}
|
||||
places={account.places}
|
||||
prefix={position.quote_currency.symbol()}
|
||||
oninput={open_price_change} />
|
||||
</Label>
|
||||
<Label title="Quantity" class="col-start-1">
|
||||
<div class="flex flex-row gap-4">
|
||||
<Toggle
|
||||
value={position.quantity.is_some()}
|
||||
onchange={quantity_toggle} />
|
||||
<Number
|
||||
class="grow"
|
||||
value={position.quantity.unwrap_or_default()}
|
||||
thousands={true}
|
||||
places={4}
|
||||
suffix=" units"
|
||||
disabled={position.quantity.is_none()}
|
||||
oninput={quantity_change} />
|
||||
</div>
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2 pt-7">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
onclick={affordable_click}
|
||||
disabled={position.quantity.is_none() || position.open_price == 0.0}>
|
||||
{"Affordable Quantity"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
onclick={stop_loss_click}
|
||||
disabled={position.stop_loss.is_none() || sl_distance == 0.0}>
|
||||
{"Stop Loss Quantity"}
|
||||
</button>
|
||||
</div>
|
||||
<Label title="Stop Loss">
|
||||
<div class="flex flex-row gap-4">
|
||||
<Toggle
|
||||
value={position.stop_loss.is_some()}
|
||||
onchange={stop_loss_toggle} />
|
||||
<Number
|
||||
class="grow"
|
||||
value={position.stop_loss.unwrap_or_default()}
|
||||
thousands={true}
|
||||
places={account.places}
|
||||
prefix={position.quote_currency.symbol()}
|
||||
disabled={position.stop_loss.is_none()}
|
||||
onchange={stop_loss_change} />
|
||||
</div>
|
||||
</Label>
|
||||
<Label title="Stop Loss Distance">
|
||||
<Number
|
||||
value={sl_distance}
|
||||
thousands={true}
|
||||
places={account.places}
|
||||
prefix={position.quote_currency.symbol()}
|
||||
disabled={position.stop_loss.is_none()}
|
||||
onchange={stop_loss_distance_change} />
|
||||
</Label>
|
||||
<Label title="Take Profit">
|
||||
<div class="flex flex-row gap-4">
|
||||
<Toggle
|
||||
value={position.take_profit.is_some()}
|
||||
onchange={take_profit_toggle} />
|
||||
<Number
|
||||
class="grow"
|
||||
value={position.take_profit.unwrap_or_default()}
|
||||
thousands={true}
|
||||
places={account.places}
|
||||
prefix={position.quote_currency.symbol()}
|
||||
disabled={position.take_profit.is_none()}
|
||||
onchange={take_profit_change} />
|
||||
</div>
|
||||
</Label>
|
||||
<Label title="Take Profit Distance">
|
||||
<Number
|
||||
value={tp_distance}
|
||||
thousands={true}
|
||||
places={account.places}
|
||||
prefix={position.quote_currency.symbol()}
|
||||
disabled={position.take_profit.is_none()}
|
||||
onchange={take_profit_distance_change} />
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(ReportPositionSize)]
|
||||
fn report_position_size() -> Html {
|
||||
let account = use_context::<AccountHandle>().expect("AccountHandle");
|
||||
let position = use_context::<PositionHandle>().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! {
|
||||
<div class="table">
|
||||
<table class="table tighter borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th colspan="2" class="text-left">{"Actual Quantity"}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"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")}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
position.quantity.unwrap_or_default() * position.open_price,
|
||||
true, account.places, Some(qc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"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
|
||||
},
|
||||
)}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
actual.cost_quote, true,
|
||||
account.places, Some(qc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
if pq {
|
||||
<tr>
|
||||
<th />
|
||||
<td>
|
||||
{format_number(
|
||||
actual.cost_position, true,
|
||||
account.places, Some(pc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
if ap {
|
||||
<tr>
|
||||
<th />
|
||||
<td>
|
||||
{format_number(
|
||||
actual.cost, true,
|
||||
account.places, Some(ac), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="flex flex-col gap-8 border border-primary rounded-md p-4">
|
||||
<h1 class="text-2xl font-semibold">{"Position Size Information"}</h1>
|
||||
<div class="table">
|
||||
<table class="table tighter borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th colspan="2" class="text-left">{"Margin Risk"}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"Amount of account available under margin risk"}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(available, true,
|
||||
account.places, Some(ac), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
if ap {
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"Available account under margin risk in the position currency"}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
available_position, true,
|
||||
account.places, Some(pc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
if pq {
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"Available account under margin risk in the quote currency"}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
available_quote, true,
|
||||
account.places, Some(qc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table">
|
||||
<table class="table tighter borderless">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th colspan="2" class="text-left">{"Position Margin and Amount"}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"Available amount with a "}
|
||||
{format_number(
|
||||
position.margin * 100.0,
|
||||
true, 2, None, Some("%")
|
||||
).expect("format_number")}
|
||||
{" position margin"}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
margin, true,
|
||||
account.places, Some(ac), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
if ap {
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"Available amount under position margin in position currency"}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
margin_position, true,
|
||||
account.places, Some(pc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
if pq {
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"Available amount under position margin in quote currency"}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
margin_quote, true,
|
||||
account.places, Some(qc), None
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<th class="text-left font-normal pl-4">
|
||||
{"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")}
|
||||
</th>
|
||||
<td class="text-right">
|
||||
{format_number(
|
||||
affordable, true,
|
||||
2, None, Some(" units")
|
||||
).expect("format_number")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{actual}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<ContextProvider<AccountHandle> context={account}>
|
||||
{props.children.clone()}
|
||||
</ContextProvider<AccountHandle>>
|
||||
}
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<ContextProvider<PositionHandle> context={position}>
|
||||
{props.children.clone()}
|
||||
</ContextProvider<PositionHandle>>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(Page)]
|
||||
pub fn page() -> Html {
|
||||
html! {
|
||||
<AccountProvider>
|
||||
<PositionProvider>
|
||||
<div class="container mx-auto my-20">
|
||||
<div class="grid grid-cols-2 gap-8">
|
||||
<AccountInfo />
|
||||
<PositionInfo />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-8 mt-8">
|
||||
<ReportPositionSize />
|
||||
</div>
|
||||
</div>
|
||||
</PositionProvider>
|
||||
</AccountProvider>
|
||||
}
|
||||
}
|
110
style/main.css
110
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 */
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user