Finance

RateAlerts

Set an alert on an exchange rate, a pair above or below a rate, and see the alerts set, how far each is, and remove them.

#finance#forex#forms#list#interactive

Demo

Preview

Rate alerts

EUR/USD is at 1.08425 now.

  • EUR/USD above 1.09000now 1.0842557.5 pips away
  • USD/JPY below 157.500now 156.245Reached

No change yet.

Branch alerts

No alerts yet.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/rate_alerts.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/rate_alerts.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/rate_alerts.qwe
let rate_alerts_demo_rates = { "EUR/USD": 1.08425m, "GBP/USD": 1.27539m, "USD/JPY": 156.2445m, "USD/PHP": 58.1225m }component RateAlertsDemoLive() {    state note = "No change yet."    <div.demo-stack>        <RateAlerts rates={rate_alerts_demo_rates}                    alerts={[{ id: "a1", pair: "EUR/USD", direction: "above", rate: 1.09m },                             { id: "a2", pair: "USD/JPY", direction: "below", rate: 157.5m }]}                    on_add={(a) => { note = `Added ${a.id}: ${a.pair} ${a.direction} ${a.rate}` }}                    on_remove={(a) => { note = `Removed ${a.id}` }}/>        <p.demo-note id="rate-alerts-demo-note">{note}</p>    </div>}view RateAlertsDemo() =    <div.demo-stack>        <RateAlertsDemoLive/>        <RateAlerts name="desk" label="Branch alerts" pairs={["USD/PHP", "EUR/PHP"]} action="#alerts" most={3}/>    </div>

Source

ui/rate_alerts.qwe
// RateAlerts: set an alert on an exchange rate: the pair, above or below, and the// rate; then the alerts set so far, each with how far the rate is from it (in pips)// or that it has been reached, and a button to remove it. A rate that is not a// number, or an alert set twice, is refused; one the rate has already passed is// set, with a warning. The fields are real (`pair`, `direction`, `rate`): with// `action` and no on_add, Add posts them, and each Remove posts `remove` with the// alert's id, so the server can keep the list.////   <RateAlerts rates={{ "EUR/USD": 1.08425m, "USD/JPY": 156.24m }} alerts={saved}//               on_add={(a) => save_alert(a)} on_remove={(a) => drop_alert(a.id)}/>//   <RateAlerts pairs={["EUR/USD", "GBP/USD"]} action="/alerts"/>//// rates: a map of pair to its rate now ({ "EUR/USD": 1.08425m }), for the distance and// the warning (decimals best; numbers are read as decimals). pairs: the pairs to offer,// in order (the rates' pairs). alerts: those set already, a list of { id, pair,// direction, rate }, direction "above" or "below". most: how many can be set (10).// label: the heading ("Rate alerts"). action: where the form posts, when there is no// on_add (none keeps the list in the page). name: put before the fields' names ("fx"// makes fx_pair, ...) and their ids. on_add(alert) and on_remove(alert): inside one of// your components, given { id, pair, direction, rate } (rate a decimal) as alerts are// added and removed.import ui/theme.qwe use ui_clslet rate_alerts_css = css`.ui-fxalert { display: grid; gap: 14px; min-width: 0; max-width: 520px; margin: 0; padding: 18px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-fxalert .ui-fxalert-title { margin: 0; font-size: 15px; font-weight: 650; }.ui-fxalert-form { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: 8px; }.ui-fxalert-field { display: grid; gap: 6px; min-width: 0; }.ui-fxalert-label { font-size: 13px; font-weight: 500; color: var(--ui-muted); }.ui-fxalert-control { box-sizing: border-box; width: 100%; height: 38px; margin: 0; padding: 0 10px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums;  transition: border-color .15s, box-shadow .15s; }.ui-fxalert-control:hover { border-color: var(--ui-strong); }.ui-fxalert-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fxalert-control[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-fxalert-add { height: 38px; padding: 0 16px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg);  font: 600 14px var(--ui-font); cursor: pointer; }.ui-fxalert-add:hover:not(:disabled) { filter: brightness(1.08); }.ui-fxalert-add:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fxalert-add:disabled { opacity: .45; cursor: not-allowed; }.ui-fxalert-now { margin: -6px 0 0; color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-fxalert-msg { margin: -6px 0 0; font-size: 13px; }.ui-fxalert-error { color: var(--ui-danger); }.ui-fxalert-warn { color: var(--ui-warning); }.ui-fxalert-list { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ui-border); }.ui-fxalert-item { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 10px; padding: 10px 0;  border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 60%, transparent); font-variant-numeric: tabular-nums; }.ui-fxalert-dir { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; font-size: 14px; font-weight: 700; }.ui-fxalert-above .ui-fxalert-dir { color: var(--ui-success); background: var(--ui-success-soft); }.ui-fxalert-below .ui-fxalert-dir { color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-fxalert-what b { font-weight: 650; }.ui-fxalert-what small { display: block; color: var(--ui-muted); font-size: 12px; }.ui-fxalert-state { padding: 2px 8px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; white-space: nowrap; }.ui-fxalert-hit .ui-fxalert-state { background: var(--ui-warning-soft); color: var(--ui-fg); font-weight: 600; }.ui-fxalert-remove { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-fxalert-remove svg { width: 14px; height: 14px; }.ui-fxalert-remove:hover { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-fxalert-remove:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fxalert-empty { margin: 0; padding: 14px 0 2px; border-top: 1px solid var(--ui-border); color: var(--ui-muted); font-size: 13px; }@media (max-width: 520px) { .ui-fxalert-form { grid-template-columns: 1fr 1fr; } }@media (prefers-reduced-motion: reduce) { .ui-fxalert-control { transition: none; } }`// A pip's size for "EUR/USD": 0.01 when the quote is priced in the hundreds, else 0.0001.fn rate_alerts_pip(pair: str) -> dec {    let parts = split(upper(pair), "/")    contains(["JPY", "HUF", "KRW", "IDR", "VND", "CLP", "ISK", "THB", "INR", "PHP", "RUB"], len(parts) > 1 ? parts[1] : "") ? 0.01m : 0.0001m}// A rate written with a pip's decimals and one more: 1.09000, 157.500.fn rate_alerts_show(pair: str, r: dec) -> str {    let places = rate_alerts_pip(pair) == 0.01m ? 3 : 5    format_number(round(r, places, "half_up"), "en-US", places)}// What was typed as a rate above 0 ("1.09" is 1.09m), or null.fn rate_alerts_read(text: str) {    let t = re.replace(trim(text), "[,\\s_]", "")    if t == "" { return null }    let d = dec(t)    is_err(d) || d <= 0 ? null : d}// Whether an alert has been reached by the rate now (false when that is not known).fn rate_alerts_hit(a, now) -> bool = now != null && (a.direction == "below" ? dec(now) <= dec(a.rate) : dec(now) >= dec(a.rate))component RateAlerts(rates: map = {}, pairs: list? = null, alerts: list = [], most: int = 10, label: str = "Rate alerts",                     action: str? = null, name: str? = null, on_add = null, on_remove = null, class: str? = null) {    style global rate_alerts_css    let offered = pairs ?? keys(rates)    let field = (f: str) => name != null ? `${name}_${f}` : f    let id = `rate-alerts-${name ?? "fx"}`    let local = action == null || on_add != null    state saved = alerts | map((a) => {        let r = dec(a.rate)        { id: str(a.id), pair: a.pair, direction: a.direction, rate: r }    })    state pair = len(offered) > 0 ? offered[0] : ""    state dir = "above"    state text = ""    state tried = false    state said = ""    state next = len(alerts) + 1    let value = rate_alerts_read(text)    let now = rates[pair]    let dupe = value != null && any(saved, (a) => a.pair == pair && a.direction == dir && a.rate == value)    let full = len(saved) >= most    let problem = value == null ? (text == "" ? "Enter the rate to be told at." : `“${text}” is not a rate.`)        : (dupe ? `There is already an alert for ${pair} ${dir} ${rate_alerts_show(pair, value)}.`        : (full ? `You can set ${most} alerts at most; remove one first.` : null))    let passed = value != null && now != null && (dir == "above" ? dec(now) >= value : dec(now) <= value)    let pid = (a) => `${id}-${a.id}`    <form class={ui_cls(["ui-fxalert", class])} method="post" action={action} aria-labelledby={`${id}-title`} novalidate>        <h3.ui-fxalert-title id={`${id}-title`}>{label}</h3>        <div.ui-fxalert-form>            <div.ui-fxalert-field>                <label.ui-fxalert-label for={`${id}-pair`}>Pair</label>                <select.ui-fxalert-control id={`${id}-pair`} name={field("pair")} @change={(e) => { pair = e.value }}>                    {for p in offered => <option key={p} value={p} selected={p == pair}>{p}</option>}                </select>            </div>            <div.ui-fxalert-field>                <label.ui-fxalert-label for={`${id}-direction`}>When it goes</label>                <select.ui-fxalert-control id={`${id}-direction`} name={field("direction")} @change={(e) => { dir = e.value }}>                    <option value="above" selected={dir == "above"}>Above</option>                    <option value="below" selected={dir == "below"}>Below</option>                </select>            </div>            <div.ui-fxalert-field>                <label.ui-fxalert-label for={`${id}-rate`}>Rate</label>                <input.ui-fxalert-control type="text" id={`${id}-rate`} name={field("rate")} value={text} inputmode="decimal" autocomplete="off"                       placeholder={now != null ? rate_alerts_show(pair, dec(now)) : null}                       aria-invalid={tried && problem != null ? "true" : null} aria-describedby={`${id}-msg`}                       @input={(e) => {                           text = e.value                           tried = false                       }}/>            </div>            <button.ui-fxalert-add type="submit" disabled={full}                    @click={(e) => {                        if local {                            e.preventDefault()                            tried = true                            let v = rate_alerts_read(text)                            if problem == null && v != null {                                let a = { id: `a${next}`, pair, direction: dir, rate: v }                                next += 1                                saved = saved + [a]                                text = ""                                tried = false                                said = `Alert set: ${pair} ${dir} ${rate_alerts_show(pair, v)}.`                                on_add && on_add(a)                            }                        }                    }}>Add</button>        </div>        {now != null && <p.ui-fxalert-now>{pair} is at {rate_alerts_show(pair, dec(now))} now.</p>}        <p id={`${id}-msg`} class={ui_cls(["ui-fxalert-msg", tried && problem != null && "ui-fxalert-error", !(tried && problem != null) && passed && "ui-fxalert-warn"])}           role={tried && problem != null ? "alert" : null}>            {tried && problem != null ? problem : (passed ? `${pair} is already ${dir} ${rate_alerts_show(pair, value)}: the alert would go at once.` : "")}        </p>        {len(saved) == 0            ? <p.ui-fxalert-empty>No alerts yet.</p>            : <ul.ui-fxalert-list aria-label="Alerts set">                  {for a in saved {                      let cur = rates[a.pair]                      let hit = rate_alerts_hit(a, cur)                      let away = cur != null ? abs(dec(a.rate) - dec(cur)) / rate_alerts_pip(a.pair) : null                      <li key={a.id} id={pid(a)} data-id={a.id} class={ui_cls(["ui-fxalert-item", `ui-fxalert-${a.direction}`, hit && "ui-fxalert-hit"])}>                          <span.ui-fxalert-dir aria-hidden="true">{a.direction == "below" ? "↓" : "↑"}</span>                          <span.ui-fxalert-what><b>{a.pair}</b> {a.direction} {rate_alerts_show(a.pair, dec(a.rate))}                              {cur != null && <small>now {rate_alerts_show(a.pair, dec(cur))}</small>}</span>                          <span.ui-fxalert-state>{hit ? "Reached" : (away != null ? `${format_number(round(away, 1, "half_up"), "en-US", 1)} pips away` : "Waiting")}</span>                          <button.ui-fxalert-remove type="submit" name={field("remove")} value={a.id}                                  aria-label={`Remove the alert for ${a.pair} ${a.direction} ${rate_alerts_show(a.pair, dec(a.rate))}`}                                  @click={(e) => {                                      if local {                                          e.preventDefault()                                          saved = saved | filter((x) => x.id != a.id)                                          said = `Alert removed: ${a.pair} ${a.direction} ${rate_alerts_show(a.pair, dec(a.rate))}.`                                          on_remove && on_remove(a)                                      }                                  }}>                              <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"/></svg>                          </button>                      </li>                  }}              </ul>}        <span.ui-sr aria-live="polite">{said}</span>    </form>}
Thread
86Useful? Like it, or add to its thread below.

Thread 0 posts

On RateAlerts: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.