Initial rebuild of position size calculator #37
@ -5,8 +5,10 @@ edition = "2021"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
panic = "abort"
|
||||||
|
codegen-units = 1
|
||||||
opt-level = 'z'
|
opt-level = 'z'
|
||||||
|
lto = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
hydration = [
|
hydration = [
|
||||||
@ -77,6 +79,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;
|
||||||
|
}
|
@ -1,9 +1,26 @@
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod blog;
|
|
||||||
pub mod content;
|
pub mod content;
|
||||||
pub mod display;
|
|
||||||
pub mod head;
|
pub mod head;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod seo;
|
pub mod seo;
|
||||||
pub mod title;
|
pub mod title;
|
||||||
|
|
||||||
|
pub mod blog {
|
||||||
|
pub mod post_card;
|
||||||
|
pub mod post_card_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod display {
|
||||||
|
pub mod bar_chart;
|
||||||
|
pub mod client_only;
|
||||||
|
pub mod clipboard;
|
||||||
|
pub mod tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod fields {
|
||||||
|
pub mod currency;
|
||||||
|
pub mod label;
|
||||||
|
pub mod number;
|
||||||
|
pub mod toggle;
|
||||||
|
}
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
pub mod post_card;
|
|
||||||
pub mod post_card_list;
|
|
@ -1 +0,0 @@
|
|||||||
pub mod bar_chart;
|
|
29
src/components/display/client_only.rs
Normal file
29
src/components/display/client_only.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
use yew::{function_component, html, use_state, Children, Html, Properties};
|
||||||
|
use yew_hooks::use_effect_once;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ClientOnlyProps {
|
||||||
|
#[prop_or_default]
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ClientOnly)]
|
||||||
|
pub fn client_only(props: &ClientOnlyProps) -> Html {
|
||||||
|
let loaded = use_state(|| false);
|
||||||
|
|
||||||
|
{
|
||||||
|
let loaded = loaded.clone();
|
||||||
|
use_effect_once(move || {
|
||||||
|
loaded.set(true);
|
||||||
|
|| {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*loaded {
|
||||||
|
html! {}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<>{props.children.clone()}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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>
|
||||||
|
}
|
||||||
|
}
|
73
src/components/display/tooltip.rs
Normal file
73
src/components/display/tooltip.rs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
use yew::{classes, function_component, html, use_state, Callback, Children, Html, Properties};
|
||||||
|
use yew_icons::{Icon, IconId};
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||||
|
pub enum TooltipPosition {
|
||||||
|
Top,
|
||||||
|
Left,
|
||||||
|
Bottom,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TooltipPosition {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct TooltipProps {
|
||||||
|
#[prop_or_default]
|
||||||
|
pub position: TooltipPosition,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Tooltip)]
|
||||||
|
pub fn tooltip(props: &TooltipProps) -> Html {
|
||||||
|
let open = use_state(|| false);
|
||||||
|
|
||||||
|
let onmouseover = {
|
||||||
|
let open = open.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
open.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let onmouseout = {
|
||||||
|
let open = open.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
open.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let popup = if *open {
|
||||||
|
html! {
|
||||||
|
<div class={classes!(
|
||||||
|
"tooltip",
|
||||||
|
match props.position {
|
||||||
|
TooltipPosition::Top => "top",
|
||||||
|
TooltipPosition::Left => "left",
|
||||||
|
TooltipPosition::Right => "right",
|
||||||
|
TooltipPosition::Bottom => "bottom"
|
||||||
|
}
|
||||||
|
)}>
|
||||||
|
<div class="relative">
|
||||||
|
{props.children.clone()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="relative inline-block cursor-pointer mx-2" {onmouseover} {onmouseout}>
|
||||||
|
<Icon
|
||||||
|
icon_id={IconId::HeroiconsSolidQuestionMarkCircle}
|
||||||
|
width={"1em".to_string()}
|
||||||
|
height={"1em".to_string()} />
|
||||||
|
{popup}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
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>
|
||||||
|
}
|
||||||
|
}
|
254
src/components/fields/number.rs
Normal file
254
src/components/fields/number.rs
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
use wasm_bindgen::{prelude::wasm_bindgen, JsValue};
|
||||||
|
use web_sys::FocusEvent;
|
||||||
|
use yew::{
|
||||||
|
classes, function_component, html, use_effect_with_deps, use_node_ref, use_state, AttrValue,
|
||||||
|
Callback, Classes, Html, Properties, TargetCast,
|
||||||
|
};
|
||||||
|
use yew_hooks::use_timeout;
|
||||||
|
use yew_icons::{Icon, IconId};
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|
||||||
|
// When the value passed to this field changes, we want to update our state.
|
||||||
|
{
|
||||||
|
let input_value = input_value.clone();
|
||||||
|
let focused = focused.clone();
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |(value, places)| {
|
||||||
|
if !*focused {
|
||||||
|
input_value.set(
|
||||||
|
format_number(*value, false, *places, None, None)
|
||||||
|
.expect("format_number to work"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(value, props.places),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = {
|
||||||
|
let focused = focused.clone();
|
||||||
|
let input_ref = input_ref.clone();
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
@ -43,8 +43,8 @@ pub fn footer(_: &FooterProps) -> Html {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4 md:gap-1 md:items-end">
|
<div class="flex flex-col gap-4 lg:gap-1 lg:items-end">
|
||||||
<div class="flex flex-col md:flex-row gap-4 md:gap-3">
|
<div class="flex flex-col md:items-end lg:items-start lg:flex-row gap-4 lg:gap-3">
|
||||||
<Link<Route> classes="hover:text-neutral-50" to={Route::Blog}>
|
<Link<Route> classes="hover:text-neutral-50" to={Route::Blog}>
|
||||||
{"Latest Posts"}
|
{"Latest Posts"}
|
||||||
</Link<Route>>
|
</Link<Route>>
|
||||||
@ -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]
|
||||||
|
146
src/model/currency.rs
Normal file
146
src/model/currency.rs
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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 {
|
||||||
|
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 })
|
||||||
|
}
|
110
src/model/trading/account.rs
Normal file
110
src/model/trading/account.rs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gloo::storage::{errors::StorageError, 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 {
|
||||||
|
Load,
|
||||||
|
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> {
|
||||||
|
let account = match action {
|
||||||
|
AccountAction::Load => Self::load(),
|
||||||
|
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
account.save();
|
||||||
|
account.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
match gloo::storage::LocalStorage::get("trading.account") {
|
||||||
|
Ok(stored) => stored,
|
||||||
|
Err(err) => match err {
|
||||||
|
StorageError::KeyNotFound(_) => {
|
||||||
|
log::info!("No stored trading account found, using defaults");
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
log::error!("Failed to retrieve trading account from local storage: {err:?}");
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
360
src/model/trading/position.rs
Normal file
360
src/model/trading/position.rs
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
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: position.stop_loss.map(|stop_loss| {
|
||||||
|
ActualStopLoss::compute(account, position, quantity, p_rate, q_rate, stop_loss)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 /> },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,8 @@ use crate::{
|
|||||||
api::{get_month_views, PageViewsMonth, PageViewsMonthResult},
|
api::{get_month_views, PageViewsMonth, PageViewsMonthResult},
|
||||||
auth::{AuthTokenContext, WithAuth},
|
auth::{AuthTokenContext, WithAuth},
|
||||||
},
|
},
|
||||||
components::display::bar_chart::BarChart,
|
components::{display::bar_chart::BarChart, seo::WebPageSeo, title::Title},
|
||||||
|
pages::Route,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn month_view_chart(
|
fn month_view_chart(
|
||||||
@ -242,8 +243,17 @@ fn dashboard_content() -> Html {
|
|||||||
#[function_component(Page)]
|
#[function_component(Page)]
|
||||||
pub fn page() -> Html {
|
pub fn page() -> Html {
|
||||||
html! {
|
html! {
|
||||||
<WithAuth>
|
<>
|
||||||
<DashboardContent />
|
<Title title={"Analytics Dashboard"} />
|
||||||
</WithAuth>
|
<WebPageSeo
|
||||||
|
route={Route::Analytics}
|
||||||
|
title={"Analytics Dashboard"}
|
||||||
|
excerpt={Some("Analytics Dashboard")}
|
||||||
|
index={false}
|
||||||
|
follow={false} />
|
||||||
|
<WithAuth>
|
||||||
|
<DashboardContent />
|
||||||
|
</WithAuth>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1375
src/pages/trading/position_size.rs
Normal file
1375
src/pages/trading/position_size.rs
Normal file
File diff suppressed because it is too large
Load Diff
195
style/main.css
195
style/main.css
@ -1,5 +1,10 @@
|
|||||||
@import "tailwind.css";
|
@import "tailwind.css";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--toggle-size: 2rem;
|
||||||
|
--primary-color: #13304e;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
body {
|
body {
|
||||||
@apply bg-white text-neutral-800;
|
@apply bg-white text-neutral-800;
|
||||||
@ -7,31 +12,171 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
@apply inline-flex items-center justify-center border border-transparent;
|
@apply inline-flex items-center justify-center;
|
||||||
|
@apply border border-transparent rounded-md;
|
||||||
|
|
||||||
@apply px-4 py-2;
|
@apply px-4 py-2;
|
||||||
@apply rounded-md shadow-sm text-sm text-gray-300 bg-primary;
|
@apply text-sm;
|
||||||
@apply disabled:bg-slate-300 dark:disabled:bg-gray-600 dark:disabled:text-gray-400;
|
|
||||||
@apply hover:text-white dark:disabled:hover:text-gray-400;
|
@apply text-neutral-200 hover:text-neutral-100;
|
||||||
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
@apply disabled:text-gray-400 dark:disabled:text-gray-800;
|
||||||
@apply transition-colors;
|
|
||||||
|
@apply bg-primary;
|
||||||
|
@apply disabled:bg-gray-200 dark:disabled:bg-gray-600;
|
||||||
|
@apply active:bg-primary-light;
|
||||||
|
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary;
|
||||||
|
@apply transition;
|
||||||
|
|
||||||
> svg {
|
> svg {
|
||||||
@apply mr-1;
|
@apply mr-1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
select,
|
select,
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="password"] {
|
input[type="password"] {
|
||||||
@apply border-primary rounded-md;
|
@apply border-primary rounded-md;
|
||||||
@apply text-neutral-800 placeholder:text-neutral-300;
|
@apply disabled:border-transparent;
|
||||||
@apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700;
|
|
||||||
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
|
||||||
|
|
||||||
&:disabled {
|
@apply text-neutral-800 placeholder:text-neutral-300;
|
||||||
@apply text-neutral-500 dark:text-neutral-500;
|
@apply dark:text-neutral-200 placeholder:text-neutral-500;
|
||||||
@apply dark:bg-zinc-900;
|
@apply disabled:text-gray-400 dark:disabled:text-gray-800;
|
||||||
|
|
||||||
|
@apply bg-white dark:bg-zinc-800;
|
||||||
|
@apply disabled:bg-gray-200 dark:disabled:bg-gray-600;
|
||||||
|
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary;
|
||||||
|
@apply transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icons {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
@apply absolute z-10 w-max max-w-xs min-h-fit p-4;
|
||||||
|
@apply text-sm text-center text-neutral-50 whitespace-pre-wrap;
|
||||||
|
@apply bg-primary border border-primary rounded-md shadow-md;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
@apply absolute w-0 h-0;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
@apply bottom-full left-1/2 -translate-x-1/2 mb-4;
|
||||||
|
&:after {
|
||||||
|
@apply top-full left-1/2 -ml-4;
|
||||||
|
border-left: 1rem solid transparent;
|
||||||
|
border-right: 1rem solid transparent;
|
||||||
|
border-top: 1rem solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
@apply top-full left-1/2 -translate-x-1/2 mt-4;
|
||||||
|
&:after {
|
||||||
|
@apply bottom-full left-1/2 -ml-4;
|
||||||
|
border-left: 1rem solid transparent;
|
||||||
|
border-right: 1rem solid transparent;
|
||||||
|
border-bottom: 1rem solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
@apply -top-3 right-full mr-4;
|
||||||
|
&:after {
|
||||||
|
@apply top-1.5 left-full mr-4;
|
||||||
|
border-top: 1rem solid transparent;
|
||||||
|
border-bottom: 1rem solid transparent;
|
||||||
|
border-left: 1rem solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
@apply -top-3 left-full ml-4;
|
||||||
|
&:after {
|
||||||
|
@apply top-1.5 right-full ml-4;
|
||||||
|
border-top: 1rem solid transparent;
|
||||||
|
border-bottom: 1rem solid transparent;
|
||||||
|
border-right: 1rem solid var(--primary-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,6 +404,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 +509,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 +536,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 +560,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 */
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ module.exports = {
|
|||||||
primary: {
|
primary: {
|
||||||
dark: "#22293D",
|
dark: "#22293D",
|
||||||
DEFAULT: "#13304E",
|
DEFAULT: "#13304E",
|
||||||
|
light: "#1C4773",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user