a bit of everything
This commit is contained in:
parent
ec755c76db
commit
88c8c95d0f
6 changed files with 857 additions and 0 deletions
325
public/btc-premium.html
Normal file
325
public/btc-premium.html
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ca">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Calculadora de compravenda de bitcoin</title>
|
||||
<style>
|
||||
:root {
|
||||
font-family: system-ui, sans-serif;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
body {
|
||||
max-width: 420px;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.sub {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label {
|
||||
flex: 0 0 5.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
.input-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-size: 1.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding: 0.5rem 3rem 0.5rem 0.6rem;
|
||||
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #f7931a;
|
||||
}
|
||||
.unit {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
pointer-events: none;
|
||||
}
|
||||
input.computed {
|
||||
border-style: dashed;
|
||||
color: #f7931a;
|
||||
}
|
||||
.rates {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
column-gap: 0.4rem;
|
||||
row-gap: 0.15rem;
|
||||
margin: -1rem 0 2rem;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.rates .lbl {
|
||||
text-align: right;
|
||||
}
|
||||
.rates .value {
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rates .value.trade-val {
|
||||
color: #f7931a;
|
||||
}
|
||||
.rates .cue {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
color: #999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.rates.is-same .value.trade-val {
|
||||
color: #999;
|
||||
}
|
||||
.rates.is-same .cue {
|
||||
opacity: 1;
|
||||
}
|
||||
.tag {
|
||||
flex: 0 0 2.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: #f7931a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
visibility: hidden;
|
||||
}
|
||||
.field.is-computed .tag {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Calculadora de compravenda de bitcoin</h1>
|
||||
<p class="sub">EUR ⇄ BTC, amb el premium inclòs</p>
|
||||
|
||||
<div class="rates">
|
||||
<span class="lbl" title="Preu de mercat (mediana) — s'usa pel càlcul, no és editable">mercat</span>
|
||||
<span class="value" id="rate">—</span>
|
||||
|
||||
<span class="cue" id="same">a preu de mercat</span>
|
||||
|
||||
<span class="lbl" title="Preu de mercat ajustat amb el teu premium — el preu real de la compravenda">operació</span>
|
||||
<span class="value trade-val" id="trade">—</span>
|
||||
</div>
|
||||
|
||||
<div class="field" id="field-eur">
|
||||
<label for="eur">EUR</label>
|
||||
<span class="input-wrap">
|
||||
<input id="eur" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00" value="1000">
|
||||
<span class="unit">€</span>
|
||||
</span>
|
||||
<span class="tag">auto</span>
|
||||
</div>
|
||||
|
||||
<div class="field" id="field-sats">
|
||||
<label for="sats">Bitcoin</label>
|
||||
<span class="input-wrap">
|
||||
<input id="sats" type="text" inputmode="numeric" autocomplete="off" placeholder="0">
|
||||
<span class="unit">sats</span>
|
||||
</span>
|
||||
<span class="tag">auto</span>
|
||||
</div>
|
||||
|
||||
<div class="field" id="field-premium">
|
||||
<label for="premium">Premium</label>
|
||||
<span class="input-wrap">
|
||||
<input id="premium" type="text" inputmode="decimal" autocomplete="off" placeholder="0" value="5">
|
||||
<span class="unit">%</span>
|
||||
</span>
|
||||
<span class="tag">auto</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SATS_PER_BTC = 100_000_000;
|
||||
const REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
// --- hidden price engine: median BTC/EUR across several exchanges ---
|
||||
// The median holds as long as at least MIN_SOURCES quotes come back valid;
|
||||
// any exchange that errors, times out, or returns junk is simply dropped.
|
||||
const MIN_SOURCES = 3;
|
||||
const SOURCES = [
|
||||
{
|
||||
url: "https://api.kraken.com/0/public/Ticker?pair=XBTEUR",
|
||||
parse: (d) => parseFloat(d.result.XXBTZEUR.c[0]),
|
||||
},
|
||||
{
|
||||
url: "https://api.coinbase.com/v2/prices/BTC-EUR/spot",
|
||||
parse: (d) => parseFloat(d.data.amount),
|
||||
},
|
||||
{
|
||||
url: "https://www.bitstamp.net/api/v2/ticker/btceur/",
|
||||
parse: (d) => parseFloat(d.last),
|
||||
},
|
||||
{
|
||||
url: "https://api-pub.bitfinex.com/v2/ticker/tBTCEUR",
|
||||
parse: (d) => parseFloat(d[6]), // LAST_PRICE
|
||||
},
|
||||
{
|
||||
url: "https://api.bitvavo.com/v2/ticker/price?market=BTC-EUR",
|
||||
parse: (d) => parseFloat(d.price),
|
||||
},
|
||||
];
|
||||
|
||||
let price = null; // EUR per BTC, median
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
// fetch one exchange, resolving to a valid positive number or null
|
||||
async function fetchQuote(source) {
|
||||
try {
|
||||
const data = await (await fetch(source.url)).json();
|
||||
const value = source.parse(data);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPrice() {
|
||||
const quotes = (await Promise.all(SOURCES.map(fetchQuote))).filter(
|
||||
(v) => v !== null
|
||||
);
|
||||
if (quotes.length < MIN_SOURCES) return; // too few sources; keep last price
|
||||
price = median(quotes);
|
||||
document.getElementById("rate").textContent =
|
||||
"€" + Math.round(price).toLocaleString("en-US") + " / BTC";
|
||||
recompute(null); // keep the derived field in sync with the new price
|
||||
}
|
||||
|
||||
// --- calculator ---
|
||||
const els = {
|
||||
eur: document.getElementById("eur"),
|
||||
sats: document.getElementById("sats"),
|
||||
premium: document.getElementById("premium"),
|
||||
};
|
||||
const fields = {
|
||||
eur: document.getElementById("field-eur"),
|
||||
sats: document.getElementById("field-sats"),
|
||||
premium: document.getElementById("field-premium"),
|
||||
};
|
||||
|
||||
// recency of user edits; last entry is the field we derive.
|
||||
// defaults: EUR (1000) and premium (5%) given, so sats is derived.
|
||||
let touchOrder = ["premium", "eur", "sats"];
|
||||
|
||||
const parse = (el) => {
|
||||
const v = parseFloat(el.value.replace(/[^0-9.\-]/g, ""));
|
||||
return isFinite(v) ? v : null;
|
||||
};
|
||||
|
||||
const fmt = {
|
||||
eur: (v) => v.toFixed(2),
|
||||
sats: (v) => Math.round(v).toLocaleString("en-US"),
|
||||
premium: (v) => parseFloat(v.toFixed(2)).toString(),
|
||||
};
|
||||
|
||||
function recompute(edited) {
|
||||
if (edited) {
|
||||
touchOrder = [edited, ...touchOrder.filter((f) => f !== edited)];
|
||||
}
|
||||
const target = touchOrder[touchOrder.length - 1];
|
||||
|
||||
// highlight which field is currently derived
|
||||
for (const f in fields) {
|
||||
fields[f].classList.toggle("is-computed", f === target);
|
||||
els[f].classList.toggle("computed", f === target);
|
||||
}
|
||||
|
||||
if (price === null) return;
|
||||
|
||||
const eur = parse(els.eur);
|
||||
const sats = parse(els.sats);
|
||||
const premium = parse(els.premium);
|
||||
|
||||
let result = null;
|
||||
if (target === "eur" && sats !== null && premium !== null) {
|
||||
result = (sats / SATS_PER_BTC) * price * (1 + premium / 100);
|
||||
} else if (target === "sats" && eur !== null && premium !== null) {
|
||||
const effective = price * (1 + premium / 100);
|
||||
if (effective > 0) result = (eur / effective) * SATS_PER_BTC;
|
||||
} else if (target === "premium" && eur !== null && sats !== null) {
|
||||
const base = (sats / SATS_PER_BTC) * price;
|
||||
if (base > 0) result = (eur / base - 1) * 100;
|
||||
}
|
||||
|
||||
els[target].value = result === null ? "" : fmt[target](result);
|
||||
|
||||
// effective trade price = market adjusted by the current premium
|
||||
// (use the just-derived premium when premium is the computed field)
|
||||
const effPremium = target === "premium" ? result : premium;
|
||||
const effective = price * (1 + (effPremium ?? 0) / 100);
|
||||
const tradeLine = document.querySelector(".rates");
|
||||
document.getElementById("trade").textContent =
|
||||
"€" + Math.round(effective).toLocaleString("en-US") + " / BTC";
|
||||
tradeLine.classList.toggle(
|
||||
"is-same",
|
||||
Math.round(effective) === Math.round(price)
|
||||
);
|
||||
}
|
||||
|
||||
// reformat a field once the user is done entering it
|
||||
const formatField = (f) => {
|
||||
const v = parse(els[f]);
|
||||
if (v !== null) els[f].value = fmt[f](v);
|
||||
};
|
||||
|
||||
for (const f in els) {
|
||||
els[f].addEventListener("input", () => {
|
||||
// sats are whole numbers only — drop anything but digits as typed
|
||||
if (f === "sats") els.sats.value = els.sats.value.replace(/[^0-9]/g, "");
|
||||
recompute(f);
|
||||
});
|
||||
els[f].addEventListener("blur", () => formatField(f));
|
||||
}
|
||||
|
||||
// normalize the pre-filled defaults (e.g. "1000" -> "1000.00")
|
||||
formatField("eur");
|
||||
formatField("premium");
|
||||
|
||||
recompute(null);
|
||||
refreshPrice();
|
||||
setInterval(refreshPrice, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue