Initial rebuild of position size calculator #37
@ -77,6 +77,8 @@ features = [
|
|||||||
"LucideBug",
|
"LucideBug",
|
||||||
"LucideCheck",
|
"LucideCheck",
|
||||||
"LucideCheckCircle",
|
"LucideCheckCircle",
|
||||||
|
"LucideClipboardCheck",
|
||||||
|
"LucideClipboardCopy",
|
||||||
"LucideFlame",
|
"LucideFlame",
|
||||||
"LucideLink",
|
"LucideLink",
|
||||||
"LucideList",
|
"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 render;
|
||||||
pub mod seo;
|
pub mod seo;
|
||||||
pub mod title;
|
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 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">
|
rel="noreferrer">
|
||||||
{"Mastodon"}
|
{"Mastodon"}
|
||||||
</a>
|
</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}>
|
<Link<Route> classes="hover:text-neutral-50" to={Route::Analytics}>
|
||||||
{"Analytics"}
|
{"Analytics"}
|
||||||
</Link<Route>>
|
</Link<Route>>
|
||||||
|
@ -6,8 +6,14 @@ use yew::{function_component, html, use_memo, Children, ContextProvider, Html, P
|
|||||||
macros::tags!("content/tags.yaml");
|
macros::tags!("content/tags.yaml");
|
||||||
|
|
||||||
pub mod blog;
|
pub mod blog;
|
||||||
|
pub mod currency;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
|
|
||||||
|
pub mod trading {
|
||||||
|
pub mod account;
|
||||||
|
pub mod position;
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ProvideTagsProps {
|
pub struct ProvideTagsProps {
|
||||||
#[prop_or_default]
|
#[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;
|
pub mod dashboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod trading {
|
||||||
|
pub mod position_size;
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Sequence, Routable)]
|
#[derive(Debug, Clone, PartialEq, Sequence, Routable)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
#[at("/")]
|
#[at("/")]
|
||||||
@ -27,6 +31,8 @@ pub enum Route {
|
|||||||
Disclaimer,
|
Disclaimer,
|
||||||
#[at("/analytics")]
|
#[at("/analytics")]
|
||||||
Analytics,
|
Analytics,
|
||||||
|
#[at("/trading/position-size")]
|
||||||
|
PositionSize,
|
||||||
#[not_found]
|
#[not_found]
|
||||||
#[at("/404")]
|
#[at("/404")]
|
||||||
NotFound,
|
NotFound,
|
||||||
@ -46,6 +52,7 @@ impl Route {
|
|||||||
Self::Disclaimer => html! { <disclaimer::Page /> },
|
Self::Disclaimer => html! { <disclaimer::Page /> },
|
||||||
Self::NotFound => html! { <not_found::Page /> },
|
Self::NotFound => html! { <not_found::Page /> },
|
||||||
Self::Analytics => html! { <analytics::dashboard::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";
|
@import "tailwind.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--toggle-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
body {
|
body {
|
||||||
@apply bg-white text-neutral-800;
|
@apply bg-white text-neutral-800;
|
||||||
@ -20,6 +24,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
select,
|
select,
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
@ -27,7 +35,9 @@
|
|||||||
@apply border-primary rounded-md;
|
@apply border-primary rounded-md;
|
||||||
@apply text-neutral-800 placeholder:text-neutral-300;
|
@apply text-neutral-800 placeholder:text-neutral-300;
|
||||||
@apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700;
|
@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 focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
||||||
|
@apply transition;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@apply text-neutral-500 dark:text-neutral-500;
|
@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 {
|
.markdown {
|
||||||
@apply flex flex-col;
|
@apply flex flex-col;
|
||||||
@apply font-sans font-normal text-lg print:text-base;
|
@apply font-sans font-normal text-lg print:text-base;
|
||||||
@ -259,6 +343,10 @@
|
|||||||
@apply text-neutral-600 dark:text-neutral-400;
|
@apply text-neutral-600 dark:text-neutral-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.table {
|
||||||
|
@apply mb-6;
|
||||||
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
@apply w-full mb-6 border-l-4 border-l-blue-400 px-8;
|
@apply w-full mb-6 border-l-4 border-l-blue-400 px-8;
|
||||||
|
|
||||||
@ -360,12 +448,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@apply min-w-full mb-6 border-collapse table-auto;
|
@apply min-w-full border-collapse table-auto;
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
@apply bg-transparent dark:bg-neutral-800;
|
@apply bg-transparent dark:bg-neutral-800;
|
||||||
@apply dark:text-white;
|
@apply dark:text-white;
|
||||||
@apply border-b border-neutral-500;
|
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
th {
|
th {
|
||||||
@ -388,8 +475,6 @@
|
|||||||
|
|
||||||
tbody {
|
tbody {
|
||||||
tr {
|
tr {
|
||||||
@apply border-b border-neutral-400 dark:border-neutral-600;
|
|
||||||
|
|
||||||
td {
|
td {
|
||||||
@apply whitespace-nowrap px-6 py-4;
|
@apply whitespace-nowrap px-6 py-4;
|
||||||
|
|
||||||
@ -414,5 +499,22 @@
|
|||||||
@apply px-3 py-2;
|
@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 */
|
} /* table */
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user