Initial rebuild of position size calculator #37

Merged
BlakeRain merged 12 commits from trading-tools into main 2023-09-25 16:48:37 +00:00
22 changed files with 2761 additions and 28 deletions

View File

@ -5,8 +5,10 @@ edition = "2021"
publish = false
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
opt-level = 'z'
lto = true
[features]
hydration = [
@ -77,6 +79,8 @@ features = [
"LucideBug",
"LucideCheck",
"LucideCheckCircle",
"LucideClipboardCheck",
"LucideClipboardCopy",
"LucideFlame",
"LucideLink",
"LucideList",

15
public/format.js Normal file
View 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;
}

View File

@ -1,9 +1,26 @@
pub mod analytics;
pub mod blog;
pub mod content;
pub mod display;
pub mod head;
pub mod layout;
pub mod render;
pub mod seo;
pub mod title;
pub mod blog {
pub mod post_card;
pub mod post_card_list;
}
pub mod display {
pub mod bar_chart;
pub mod client_only;
pub mod clipboard;
pub mod tooltip;
}
pub mod fields {
pub mod currency;
pub mod label;
pub mod number;
pub mod toggle;
}

View File

@ -1,2 +0,0 @@
pub mod post_card;
pub mod post_card_list;

View File

@ -1 +0,0 @@
pub mod bar_chart;

View 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()}</>
}
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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
}
}

View 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>
}
}

View File

@ -43,8 +43,8 @@ pub fn footer(_: &FooterProps) -> Html {
</a>
</div>
</div>
<div class="flex flex-col gap-4 md:gap-1 md:items-end">
<div class="flex flex-col md:flex-row gap-4 md:gap-3">
<div class="flex flex-col gap-4 lg:gap-1 lg:items-end">
<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}>
{"Latest Posts"}
</Link<Route>>
@ -65,6 +65,9 @@ pub fn footer(_: &FooterProps) -> Html {
rel="noreferrer">
{"Mastodon"}
</a>
<Link<Route> classes="hover:text-neutral-50" to={Route::PositionSize}>
{"Position Size Calculator"}
</Link<Route>>
<Link<Route> classes="hover:text-neutral-50" to={Route::Analytics}>
{"Analytics"}
</Link<Route>>

View File

@ -6,8 +6,14 @@ use yew::{function_component, html, use_memo, Children, ContextProvider, Html, P
macros::tags!("content/tags.yaml");
pub mod blog;
pub mod currency;
pub mod pages;
pub mod trading {
pub mod account;
pub mod position;
}
#[derive(Properties, PartialEq)]
pub struct ProvideTagsProps {
#[prop_or_default]

146
src/model/currency.rs Normal file
View 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 })
}

View 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:?}");
}
}
}

View 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,
}
}
}

View File

@ -13,6 +13,10 @@ mod analytics {
pub mod dashboard;
}
mod trading {
pub mod position_size;
}
#[derive(Debug, Clone, PartialEq, Sequence, Routable)]
pub enum Route {
#[at("/")]
@ -27,6 +31,8 @@ pub enum Route {
Disclaimer,
#[at("/analytics")]
Analytics,
#[at("/trading/position-size")]
PositionSize,
#[not_found]
#[at("/404")]
NotFound,
@ -46,6 +52,7 @@ impl Route {
Self::Disclaimer => html! { <disclaimer::Page /> },
Self::NotFound => html! { <not_found::Page /> },
Self::Analytics => html! { <analytics::dashboard::Page /> },
Self::PositionSize => html! { <trading::position_size::Page /> },
}
}
}

View File

@ -9,7 +9,8 @@ use crate::{
api::{get_month_views, PageViewsMonth, PageViewsMonthResult},
auth::{AuthTokenContext, WithAuth},
},
components::display::bar_chart::BarChart,
components::{display::bar_chart::BarChart, seo::WebPageSeo, title::Title},
pages::Route,
};
fn month_view_chart(
@ -242,8 +243,17 @@ fn dashboard_content() -> Html {
#[function_component(Page)]
pub fn page() -> Html {
html! {
<WithAuth>
<DashboardContent />
</WithAuth>
<>
<Title title={"Analytics Dashboard"} />
<WebPageSeo
route={Route::Analytics}
title={"Analytics Dashboard"}
excerpt={Some("Analytics Dashboard")}
index={false}
follow={false} />
<WithAuth>
<DashboardContent />
</WithAuth>
</>
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
@import "tailwind.css";
:root {
--toggle-size: 2rem;
--primary-color: #13304e;
}
@layer components {
body {
@apply bg-white text-neutral-800;
@ -7,31 +12,171 @@
}
.button {
@apply inline-flex items-center justify-center border border-transparent;
@apply inline-flex items-center justify-center;
@apply border border-transparent rounded-md;
@apply px-4 py-2;
@apply rounded-md shadow-sm text-sm text-gray-300 bg-primary;
@apply disabled:bg-slate-300 dark:disabled:bg-gray-600 dark:disabled:text-gray-400;
@apply hover:text-white dark:disabled:hover:text-gray-400;
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
@apply transition-colors;
@apply text-sm;
@apply text-neutral-200 hover:text-neutral-100;
@apply disabled:text-gray-400 dark:disabled:text-gray-800;
@apply bg-primary;
@apply disabled:bg-gray-200 dark:disabled:bg-gray-600;
@apply active:bg-primary-light;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary;
@apply transition;
> svg {
@apply mr-1;
}
}
label {
@apply font-semibold;
}
select,
input[type="text"],
input[type="number"],
input[type="password"] {
@apply border-primary rounded-md;
@apply text-neutral-800 placeholder:text-neutral-300;
@apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700;
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
@apply disabled:border-transparent;
&:disabled {
@apply text-neutral-500 dark:text-neutral-500;
@apply dark:bg-zinc-900;
@apply text-neutral-800 placeholder:text-neutral-300;
@apply dark:text-neutral-200 placeholder:text-neutral-500;
@apply disabled:text-gray-400 dark:disabled:text-gray-800;
@apply bg-white dark:bg-zinc-800;
@apply disabled:bg-gray-200 dark:disabled:bg-gray-600;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary;
@apply transition;
}
.input-icons {
@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;
}
div.table {
@apply mb-6;
}
blockquote {
@apply w-full mb-6 border-l-4 border-l-blue-400 px-8;
@ -360,12 +509,11 @@
}
table {
@apply min-w-full mb-6 border-collapse table-auto;
@apply min-w-full border-collapse table-auto;
thead {
@apply bg-transparent dark:bg-neutral-800;
@apply dark:text-white;
@apply border-b border-neutral-500;
tr {
th {
@ -388,8 +536,6 @@
tbody {
tr {
@apply border-b border-neutral-400 dark:border-neutral-600;
td {
@apply whitespace-nowrap px-6 py-4;
@ -414,5 +560,22 @@
@apply px-3 py-2;
}
}
&.tighter {
thead tr th,
tbody tr td {
@apply px-1 py-1;
}
}
&:not(.borderless) {
thead {
@apply border-b border-neutral-500;
}
tbody tr {
@apply border-b border-neutral-400 dark:border-neutral-600;
}
}
} /* table */
}

View File

@ -5,6 +5,7 @@ module.exports = {
primary: {
dark: "#22293D",
DEFAULT: "#13304E",
light: "#1C4773",
},
},
},