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
17 changed files with 1972 additions and 4 deletions
Showing only changes of commit c4429d0e00 - Show all commits

View File

@ -77,6 +77,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

@ -7,3 +7,10 @@ pub mod layout;
pub mod render;
pub mod seo;
pub mod title;
pub mod fields {
pub mod currency;
pub mod label;
pub mod number;
pub mod toggle;
}

View File

@ -1 +1,2 @@
pub mod bar_chart;
pub mod clipboard;

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

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

@ -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]

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

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

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

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

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

View File

@ -1,5 +1,9 @@
@import "tailwind.css";
:root {
--toggle-size: 2rem;
}
@layer components {
body {
@apply bg-white text-neutral-800;
@ -20,6 +24,10 @@
}
}
label {
@apply font-semibold;
}
select,
input[type="text"],
input[type="number"],
@ -27,7 +35,9 @@
@apply border-primary rounded-md;
@apply text-neutral-800 placeholder:text-neutral-300;
@apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700;
@apply disabled:bg-gray-200 dark:disabled:bg-zinc-900 dark:disabled:text-neutral-500;
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
@apply transition;
&:disabled {
@apply text-neutral-500 dark:text-neutral-500;
@ -35,6 +45,80 @@
}
}
.input-icons {
@apply relative;
svg:first-child,
svg:last-child {
@apply pointer-events-none absolute top-1/2 -mt-2.5 text-gray-300 dark:text-gray-500;
}
svg:first-child {
@apply left-3;
}
svg:last-child {
@apply right-3;
}
select,
input[type="text"],
input[type="number"],
input[type="password"] {
&.icon-left {
@apply pl-10;
}
&.icon-right {
@apply pr-10;
}
}
}
.toggle {
@apply block relative cursor-pointer;
width: calc(2 * var(--toggle-size));
height: var(--toggle-size);
&.active {
.toggle-background {
@apply bg-slate-800 dark:bg-indigo-300;
}
.toggle-inner {
right: 0rem;
}
}
.toggle-background {
@apply absolute bg-slate-300 dark:bg-gray-600;
width: calc(2 * var(--toggle-size));
height: calc(0.5 * var(--toggle-size));
top: calc(0.25 * var(--toggle-size));
border-radius: calc(0.5 * var(--toggle-size));
transition: background-color 0.125s ease-in-out;
}
.toggle-inner {
@apply absolute bg-white;
width: var(--toggle-size);
height: var(--toggle-size);
top: 0;
right: var(--toggle-size);
border-radius: calc(0.5 * var(--toggle-size));
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
transition: right 0.125s ease-in-out;
}
}
.markdown {
@apply flex flex-col;
@apply font-sans font-normal text-lg print:text-base;
@ -259,6 +343,10 @@
@apply text-neutral-600 dark:text-neutral-400;
}
div.table {
@apply mb-6;
}
blockquote {
@apply w-full mb-6 border-l-4 border-l-blue-400 px-8;
@ -360,12 +448,11 @@
}
table {
@apply min-w-full mb-6 border-collapse table-auto;
@apply min-w-full border-collapse table-auto;
thead {
@apply bg-transparent dark:bg-neutral-800;
@apply dark:text-white;
@apply border-b border-neutral-500;
tr {
th {
@ -388,8 +475,6 @@
tbody {
tr {
@apply border-b border-neutral-400 dark:border-neutral-600;
td {
@apply whitespace-nowrap px-6 py-4;
@ -414,5 +499,22 @@
@apply px-3 py-2;
}
}
&.tighter {
thead tr th,
tbody tr td {
@apply px-1 py-1;
}
}
&:not(.borderless) {
thead {
@apply border-b border-neutral-500;
}
tbody tr {
@apply border-b border-neutral-400 dark:border-neutral-600;
}
}
} /* table */
}