initial commit
This commit is contained in:
commit
8f6423172d
23 changed files with 742 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
@ -0,0 +1 @@
|
||||||
|
VITE_DB_PATH= # absolute path to the haveno_statistics/xmr_mainnet/db/ folder generate by the haveno-statsnode
|
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
src/lib/data
|
1
.npmrc
Normal file
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
5
README.md
Normal file
5
README.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# haveno-markets
|
||||||
|
## Requirements
|
||||||
|
* Haveno
|
||||||
|
* ./haveno-statsnode --dumpStatistics=true
|
||||||
|
* bun
|
21
biome.json
Normal file
21
biome.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"useNodejsImportProtocol": "off",
|
||||||
|
"useConst": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": ["*.svelte"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
27
package.json
Normal file
27
package.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "haveno-markets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"fix": "biome check --write *",
|
||||||
|
"lint": "biome check *"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.8.3",
|
||||||
|
"@sveltejs/kit": "^2.5.18",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
||||||
|
"sass": "^1.77.8",
|
||||||
|
"svelte": "^4.2.18",
|
||||||
|
"svelte-adapter-bun": "^0.5.2",
|
||||||
|
"svelte-preprocess": "^5.1.4",
|
||||||
|
"vite": "^5.3.3"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"lightweight-charts": "^4.1.7",
|
||||||
|
"svelte-lightweight-charts": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
13
src/app.html
Normal file
13
src/app.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Haveno Markets</title>
|
||||||
|
<link rel="icon" href="data:;base64,=">
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
60
src/lib/formatPrice.js
Normal file
60
src/lib/formatPrice.js
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { get, writable } from "svelte/store";
|
||||||
|
|
||||||
|
const crypto = writable([]);
|
||||||
|
const fiat = writable([]);
|
||||||
|
|
||||||
|
const isMoneroQuote = (currency) => {
|
||||||
|
return !!get(crypto).find((e) => e.code === currency);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAsset = (currency) => {
|
||||||
|
return (
|
||||||
|
get(crypto).find((e) => e.code === currency) ||
|
||||||
|
get(fiat).find((e) => e.code === currency)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSignificantDigits = (price) => {
|
||||||
|
const avg =
|
||||||
|
price.length > 0 ? price.reduce((a, b) => a + b) / price.length : price;
|
||||||
|
let i = -4;
|
||||||
|
for (; i < 20; i++) {
|
||||||
|
if (Math.floor(avg * 10 ** i) >= 1000) break;
|
||||||
|
}
|
||||||
|
if (i <= 1) i = 2;
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPrice = (price, currency = "XMR", useQuote = true) => {
|
||||||
|
return isMoneroQuote(currency) && useQuote
|
||||||
|
? 10 ** getAsset(currency).precision / price
|
||||||
|
: price / 10 ** getAsset(currency).precision;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (
|
||||||
|
price,
|
||||||
|
currency = "XMR",
|
||||||
|
showSign = false,
|
||||||
|
useQuote = true,
|
||||||
|
) => {
|
||||||
|
const calculatedPrice = getPrice(price, currency, useQuote);
|
||||||
|
return (
|
||||||
|
(showSign
|
||||||
|
? getAsset(isMoneroQuote(currency) ? "XMR" : currency).sign || ""
|
||||||
|
: "") +
|
||||||
|
calculatedPrice.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: getSignificantDigits(calculatedPrice),
|
||||||
|
maximumFractionDigits: getSignificantDigits(calculatedPrice),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
formatPrice,
|
||||||
|
getPrice,
|
||||||
|
getSignificantDigits,
|
||||||
|
getAsset,
|
||||||
|
isMoneroQuote,
|
||||||
|
crypto,
|
||||||
|
fiat,
|
||||||
|
};
|
88
src/lib/server/context.js
Normal file
88
src/lib/server/context.js
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import { watch } from "fs";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
const offers = writable([]);
|
||||||
|
const trades = writable([]);
|
||||||
|
const crypto = writable([]);
|
||||||
|
const fiat = writable([]);
|
||||||
|
|
||||||
|
const formatTrades = (e) => {
|
||||||
|
return e.map((e) => {
|
||||||
|
const crypto = e.primaryMarketTradeVolume === e.tradeAmount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency: e.currency,
|
||||||
|
price: e.tradePrice,
|
||||||
|
xmrAmount: e.tradeAmount,
|
||||||
|
amount: crypto ? e.primaryMarketTradeAmount : e.primaryMarketTradeVolume,
|
||||||
|
date: e.tradeDate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const formatOffers = (e) => {
|
||||||
|
return Object.groupBy(e, ({ currencyCode }) => currencyCode);
|
||||||
|
};
|
||||||
|
const formatCrypto = (e) => {
|
||||||
|
e[e.findIndex((e) => e.code === "XMR")].precision = 12;
|
||||||
|
e[e.findIndex((e) => e.code === "XMR")].sign = "ɱ";
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
const formatFiat = (e) => {
|
||||||
|
e[e.findIndex((e) => e.code === "USD")].sign = "$";
|
||||||
|
e[e.findIndex((e) => e.code === "EUR")].sign = "€";
|
||||||
|
e[e.findIndex((e) => e.code === "GBP")].sign = "£";
|
||||||
|
e[e.findIndex((e) => e.code === "AUD")].sign = "$";
|
||||||
|
e[e.findIndex((e) => e.code === "CAD")].sign = "$";
|
||||||
|
e[e.findIndex((e) => e.code === "SEK")].sign = "kr";
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
Bun.file(`${import.meta.env.VITE_DB_PATH}offers_statistics.json`)
|
||||||
|
.json()
|
||||||
|
.then((j) => {
|
||||||
|
offers.set(formatOffers(j));
|
||||||
|
});
|
||||||
|
Bun.file(`${import.meta.env.VITE_DB_PATH}trade_statistics.json`)
|
||||||
|
.json()
|
||||||
|
.then((j) => {
|
||||||
|
trades.set(formatTrades(j));
|
||||||
|
});
|
||||||
|
Bun.file(`${import.meta.env.VITE_DB_PATH}crypto_currency_list.json`)
|
||||||
|
.json()
|
||||||
|
.then((j) => {
|
||||||
|
crypto.set(formatCrypto(j));
|
||||||
|
});
|
||||||
|
Bun.file(`${import.meta.env.VITE_DB_PATH}/traditional_currency_list.json`)
|
||||||
|
.json()
|
||||||
|
.then((j) => {
|
||||||
|
fiat.set(formatFiat(j));
|
||||||
|
});
|
||||||
|
|
||||||
|
const watcher = watch(import.meta.env.VITE_DB_PATH, async (_, filename) => {
|
||||||
|
const file = Bun.file(import.meta.env.VITE_DB_PATH + filename);
|
||||||
|
const contents = await file.json();
|
||||||
|
switch (filename) {
|
||||||
|
case "offers_statistics.json":
|
||||||
|
offers.set(formatOffers(contents));
|
||||||
|
break;
|
||||||
|
case "trade_statistics.json":
|
||||||
|
trades.set(formatTrades(contents));
|
||||||
|
break;
|
||||||
|
case "crypto_currency_list.json":
|
||||||
|
crypto.set(formatCrypto(contents));
|
||||||
|
break;
|
||||||
|
case "traditional_currency_list.json":
|
||||||
|
fiat.set(formatFiat(contents));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
// close watcher when Ctrl-C is pressed
|
||||||
|
console.log("Closing watcher...");
|
||||||
|
watcher.close();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { offers, trades, crypto, fiat };
|
6
src/routes/+layout.js
Normal file
6
src/routes/+layout.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { crypto, fiat } from "$lib/formatPrice";
|
||||||
|
|
||||||
|
export function load({ data }) {
|
||||||
|
crypto.set(data.crypto);
|
||||||
|
fiat.set(data.fiat);
|
||||||
|
}
|
6
src/routes/+layout.server.js
Normal file
6
src/routes/+layout.server.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { crypto, fiat } from "$lib/server/context";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
return { crypto: get(crypto), fiat: get(fiat) };
|
||||||
|
}
|
121
src/routes/+layout.svelte
Normal file
121
src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
<div class="col app">
|
||||||
|
<div class="row header">
|
||||||
|
<span style="display:flex;align-items:center;gap:.2em;width:128px">
|
||||||
|
<img src="/haveno_logo_icon.png" alt="" style="height:1em;"/>
|
||||||
|
<a href="https://haveno.exchange">haveno.exchange</a>
|
||||||
|
</span>
|
||||||
|
<a href="/">haveno.markets</a>
|
||||||
|
<span style="display:flex;align-items:center;gap:.2em;width:128px">
|
||||||
|
<img src="/monero-symbol-on-white-1280.png" alt="" style="height:1em;"/>
|
||||||
|
<a href="https://xmrchain.net">xmrchain.net</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col container">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<div class="row footer">
|
||||||
|
<a href="monero:84LnuW3YpCQirNMN6y6Px1E3DfwnqwXVRARi9eHjzeSVFJqEQmJCxkP5WpkysbcktqUNhXxQLowhJGSknNjJWZNQ7FKp5bu">
|
||||||
|
84LnuW3YpCQirNMN6y6Px1E3DfwnqwXVRARi9eHjzeSVFJqEQmJCxkP5WpkysbcktqUNhXxQLowhJGSknNjJWZNQ7FKp5bu
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style lang="scss" global>
|
||||||
|
.app {
|
||||||
|
display:flex;
|
||||||
|
width:100%;
|
||||||
|
justify-content:center;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width:80%!important;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color:#5555;
|
||||||
|
padding:1em 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color:#4444;
|
||||||
|
margin-top:auto;
|
||||||
|
}
|
||||||
|
.col{
|
||||||
|
display:flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display:flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color:#290040;
|
||||||
|
color:white;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin:1em;
|
||||||
|
padding:.5em;
|
||||||
|
background-color:#552A64;
|
||||||
|
border-radius: 5px;
|
||||||
|
:global(h4) {
|
||||||
|
text-align: center;
|
||||||
|
color:#F1482D;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse:collapse;
|
||||||
|
th, td {
|
||||||
|
text-align:right;
|
||||||
|
padding:.3em;
|
||||||
|
}
|
||||||
|
th:first-child, td:first-child{
|
||||||
|
text-align:left;
|
||||||
|
}
|
||||||
|
tr:nth-child(2n){
|
||||||
|
background-color: #0002;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, .price {
|
||||||
|
text-decoration: none;
|
||||||
|
color:#f60;
|
||||||
|
}
|
||||||
|
a:hover{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size:2em;
|
||||||
|
margin-bottom:.4em;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
margin:.4em;
|
||||||
|
}
|
||||||
|
.trade-currency {
|
||||||
|
font-size:1em;
|
||||||
|
color:#fff6;
|
||||||
|
font-weight:bold;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 97%!important;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin:.5em 0;
|
||||||
|
padding:.5em 0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
padding:.2em 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
6
src/routes/+page.server.js
Normal file
6
src/routes/+page.server.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { offers, trades } from "$lib/server/context";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
return { trades: get(trades), offers: get(offers) };
|
||||||
|
}
|
202
src/routes/+page.svelte
Normal file
202
src/routes/+page.svelte
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
formatPrice,
|
||||||
|
getAsset,
|
||||||
|
getPrice,
|
||||||
|
getSignificantDigits,
|
||||||
|
} from "$lib/formatPrice";
|
||||||
|
import {
|
||||||
|
CandlestickSeries,
|
||||||
|
Chart,
|
||||||
|
HistogramSeries,
|
||||||
|
LineSeries,
|
||||||
|
PriceScale,
|
||||||
|
TimeScale,
|
||||||
|
} from "svelte-lightweight-charts";
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
const grouped = Object.groupBy(data.trades, ({ currency }) => currency);
|
||||||
|
let trades = {};
|
||||||
|
let interval = "86400000";
|
||||||
|
let key = "USD";
|
||||||
|
const getData = () => {
|
||||||
|
trades = grouped[key]
|
||||||
|
.map((e) => {
|
||||||
|
return {
|
||||||
|
time: new Date(e.date),
|
||||||
|
value: getPrice(e.price, e.currency),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.toSorted((a, b) => (a.time > b.time ? 1 : -1));
|
||||||
|
|
||||||
|
trades = Object.groupBy(
|
||||||
|
trades,
|
||||||
|
({ time }) => new Date(time - (time % interval)) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const intervalDate in trades) {
|
||||||
|
trades[intervalDate] = trades[intervalDate].reduce((a, c) => {
|
||||||
|
return {
|
||||||
|
open: a.open ?? c.value,
|
||||||
|
close: c.value,
|
||||||
|
high: (c.value > a.high ? c.value : a.high) ?? c.value,
|
||||||
|
low: (c.value < a.low ? c.value : a.low) ?? c.value,
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
trades[intervalDate].time = Number.parseInt(intervalDate, 10);
|
||||||
|
}
|
||||||
|
trades = Object.values(trades);
|
||||||
|
};
|
||||||
|
let volume = {};
|
||||||
|
let swaps = {};
|
||||||
|
const getVolume = () => {
|
||||||
|
volume = Object.groupBy(
|
||||||
|
data.trades
|
||||||
|
.map((e) => {
|
||||||
|
return {
|
||||||
|
volume: e.xmrAmount,
|
||||||
|
time: e.date,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.toSorted((a, b) => (a.time > b.time ? 1 : -1)),
|
||||||
|
({ time }) => new Date(time - (time % interval)) / 1000,
|
||||||
|
);
|
||||||
|
swaps = {};
|
||||||
|
for (const intervalDate in volume) {
|
||||||
|
swaps[intervalDate] = volume[intervalDate].reduce(
|
||||||
|
(a) => {
|
||||||
|
return {
|
||||||
|
value: a.value + 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ value: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
volume[intervalDate] = volume[intervalDate].reduce(
|
||||||
|
(a, c) => {
|
||||||
|
return {
|
||||||
|
value: a.value + c.volume / 10 ** 12,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ value: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
volume[intervalDate].time = Number.parseInt(intervalDate, 10);
|
||||||
|
swaps[intervalDate].time = Number.parseInt(intervalDate, 10);
|
||||||
|
}
|
||||||
|
volume = Object.values(volume);
|
||||||
|
swaps = Object.values(swaps);
|
||||||
|
};
|
||||||
|
let precision = 1e-2;
|
||||||
|
$: {
|
||||||
|
getVolume();
|
||||||
|
getData();
|
||||||
|
precision = getSignificantDigits(trades.flatMap((e) => [e.open, e.close]));
|
||||||
|
interval;
|
||||||
|
key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartLayout = {
|
||||||
|
background: {
|
||||||
|
color: "#090020",
|
||||||
|
},
|
||||||
|
textColor: "#f6efff",
|
||||||
|
};
|
||||||
|
const gridLayout = {
|
||||||
|
vertLines: {
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
horzLines: {
|
||||||
|
color: "#FFF5",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let w;
|
||||||
|
const liquidity = Object.values(data.offers)
|
||||||
|
.flat()
|
||||||
|
.reduce((a, b) => a + Number.parseInt(b.amount), 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Haveno Markets</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card">
|
||||||
|
<h4>XMR/USD</h4>
|
||||||
|
<span class="price">{formatPrice(grouped["USD"][0].price, "USD", true)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col card">
|
||||||
|
<h4>Liquidity</h4>
|
||||||
|
<span class="price">{formatPrice(liquidity, "XMR", true, false)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card" style="flex:1;" bind:clientWidth={w}>
|
||||||
|
<h4>Price XMR/<select bind:value={key}>
|
||||||
|
{#each Object.keys(grouped) as key}
|
||||||
|
<option>{key}</option>
|
||||||
|
{/each}
|
||||||
|
</select></h4>
|
||||||
|
|
||||||
|
<Chart width={w-20} height={300} container={{class:"row"}} layout={chartLayout} grid={gridLayout}>
|
||||||
|
<CandlestickSeries data={trades} reactive={true} priceFormat={{minMove:10**-precision, precision:precision}}></CandlestickSeries>
|
||||||
|
<TimeScale rightBarStaysOnScroll={true} rightOffset={0}/>
|
||||||
|
</Chart>
|
||||||
|
</div>
|
||||||
|
<div class="col card" style="flex:1">
|
||||||
|
<h4>
|
||||||
|
<select bind:value={interval}>
|
||||||
|
<option value="3600000">Hourly</option>
|
||||||
|
<option value="86400000">Daily</option>
|
||||||
|
<option value="604800000">Weekly</option>
|
||||||
|
</select> Volume</h4>
|
||||||
|
<Chart width={w-20} height={300} container={{class:"row"}} layout={chartLayout} grid={gridLayout}>
|
||||||
|
<LineSeries data={volume} reactive={true} priceFormat={{precision:2, minMove:.01}}>
|
||||||
|
<PriceScale scaleMargins={{bottom:.4, top:.1}}/>
|
||||||
|
</LineSeries>
|
||||||
|
<HistogramSeries data={swaps} reactive={true} priceScaleId="" priceFormat={{precision:0, minMove:1}}>
|
||||||
|
<PriceScale scaleMargins={{top:.7, bottom:0}}/>
|
||||||
|
</HistogramSeries>
|
||||||
|
<TimeScale rightBarStaysOnScroll={true} rightOffset={0}/>
|
||||||
|
</Chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="card col">
|
||||||
|
<h4>Markets</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Currency</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Trades</th>
|
||||||
|
</tr>
|
||||||
|
{#each Object.values(Object.groupBy(data.trades, ({currency}) => currency)).toSorted((a,b) => b.length - a.length || (b[0].currency < a[0].currency ? 1 : -1)).slice(0, 16) as market}
|
||||||
|
<tr>
|
||||||
|
<td><a href="market/{market[0].currency}">{getAsset(market[0].currency).name} ({market[0].currency})</a></td>
|
||||||
|
<td>{formatPrice(market[0].price, market[0].currency, true, false)}</td>
|
||||||
|
<td>{market.length}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
<h4><a href="markets">View more »</a></h4>
|
||||||
|
</div>
|
||||||
|
<div class="card col">
|
||||||
|
<h4>Trades</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Amount (XMR)</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
{#each data.trades.slice(0, 16) as trade}
|
||||||
|
<tr>
|
||||||
|
<td>{new Date(trade.date).toISOString().replace("T", " ").replace(/\.\d*Z/, "")}</td>
|
||||||
|
<td>{formatPrice(trade.xmrAmount, "XMR", false, false)}</td>
|
||||||
|
<td>{formatPrice(trade.amount, trade.currency, false, false)} <span class="trade-currency">{trade.currency}</span></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
<h4><a href="trades">View more »</a></h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
16
src/routes/market/[market]/+page.server.js
Normal file
16
src/routes/market/[market]/+page.server.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { offers, trades } from "$lib/server/context";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
export async function load({ params }) {
|
||||||
|
let groupedOffers = { BUY: [], SELL: [] };
|
||||||
|
if (get(offers)[params.market]?.length > 0) {
|
||||||
|
groupedOffers = Object.groupBy(
|
||||||
|
get(offers)[params.market],
|
||||||
|
({ direction }) => direction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
trades: get(trades).filter(({ currency }) => currency === params.market),
|
||||||
|
offers: groupedOffers,
|
||||||
|
};
|
||||||
|
}
|
138
src/routes/market/[market]/+page.svelte
Normal file
138
src/routes/market/[market]/+page.svelte
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
<script>
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import {
|
||||||
|
formatPrice,
|
||||||
|
getPrice,
|
||||||
|
getSignificantDigits,
|
||||||
|
isMoneroQuote,
|
||||||
|
} from "$lib/formatPrice";
|
||||||
|
import { CandlestickSeries, Chart, TimeScale } from "svelte-lightweight-charts";
|
||||||
|
|
||||||
|
const market = $page.params.market;
|
||||||
|
export let data;
|
||||||
|
const interval = 86400000;
|
||||||
|
let trades = [];
|
||||||
|
const getData = () => {
|
||||||
|
trades = data.trades
|
||||||
|
.map((e) => {
|
||||||
|
return {
|
||||||
|
time: new Date(e.date),
|
||||||
|
value: getPrice(e.price, e.currency, false, false),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.toSorted((a, b) => (a.time > b.time ? 1 : -1));
|
||||||
|
|
||||||
|
trades = Object.groupBy(
|
||||||
|
trades,
|
||||||
|
({ time }) => new Date(time - (time % interval)) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const intervalDate in trades) {
|
||||||
|
trades[intervalDate] = trades[intervalDate].reduce((a, c) => {
|
||||||
|
return {
|
||||||
|
open: a.open ?? c.value,
|
||||||
|
close: c.value,
|
||||||
|
high: (c.value > a.high ? c.value : a.high) ?? c.value,
|
||||||
|
low: (c.value < a.low ? c.value : a.low) ?? c.value,
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
trades[intervalDate].time = Number.parseInt(intervalDate, 10);
|
||||||
|
}
|
||||||
|
trades = Object.values(trades);
|
||||||
|
};
|
||||||
|
let w;
|
||||||
|
|
||||||
|
let precision = 1e-2;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
getData();
|
||||||
|
precision = getSignificantDigits(trades.flatMap((e) => [e.open, e.close]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartLayout = {
|
||||||
|
background: {
|
||||||
|
color: "#090020",
|
||||||
|
},
|
||||||
|
textColor: "#f6efff",
|
||||||
|
};
|
||||||
|
const gridLayout = {
|
||||||
|
vertLines: {
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
horzLines: {
|
||||||
|
color: "#FFF5",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const marketPair = isMoneroQuote(market) ? `${market}/XMR` : `XMR/${market}`;
|
||||||
|
</script>
|
||||||
|
<svelte:head>
|
||||||
|
<title>{marketPair} - Haveno Markets</title>
|
||||||
|
</svelte:head>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card" bind:clientWidth={w}>
|
||||||
|
<h4>{marketPair}</h4>
|
||||||
|
<span class="price">{formatPrice(data.trades?.[0]?.price, market, true, false)}</span>
|
||||||
|
<Chart width={w-20} height={500} container={{class:"row"}} layout={chartLayout} grid={gridLayout}>
|
||||||
|
<CandlestickSeries data={trades} reactive={true} priceFormat={{minMove:10**-precision, precision:precision}}></CandlestickSeries>
|
||||||
|
<TimeScale rightBarStaysOnScroll={true} rightOffset={0}/>
|
||||||
|
</Chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card">
|
||||||
|
<h4>Buy Offers</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Amount (XMR)</th>
|
||||||
|
<th>Amount ({market})</th>
|
||||||
|
</tr>
|
||||||
|
{#each data.offers["BUY"]?.toSorted((a,b) => a.price < b.price ? 1 : -1)||[] as offer}
|
||||||
|
<tr title={offer.paymentMethod}>
|
||||||
|
<td>{formatPrice(offer.price, market, false, false)}</td>
|
||||||
|
<td>{formatPrice(offer.amount, "XMR", false, false)}</td>
|
||||||
|
<td>{formatPrice(offer.primaryMarketAmount, market, false, false)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col card">
|
||||||
|
<h4>Sell Offers</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Amount (XMR)</th>
|
||||||
|
<th>Amount ({market})</th>
|
||||||
|
</tr>
|
||||||
|
{#each data.offers["SELL"]?.toSorted((a,b) => a.price > b.price ? 1 : -1)||[] as offer}
|
||||||
|
<tr title={offer.paymentMethod}>
|
||||||
|
<td>{formatPrice(offer.price, market, false, false)}</td>
|
||||||
|
<td>{formatPrice(offer.amount, "XMR", false, false)}</td>
|
||||||
|
<td>{formatPrice(offer.primaryMarketAmount, market, false, false)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col card">
|
||||||
|
<h4>Last Trades</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Amount (XMR)</th>
|
||||||
|
<th>Amount ({market})</th>
|
||||||
|
</tr>
|
||||||
|
{#each data.trades as trade}
|
||||||
|
<tr>
|
||||||
|
<td>{new Date(trade.date).toISOString().replace("T", " ").replace(/\.\d*Z/, "")}</td>
|
||||||
|
<td>{formatPrice(trade.price, trade.currency, false, false)}</td>
|
||||||
|
<td>{formatPrice(trade.xmrAmount, "XMR", false, false)}</td>
|
||||||
|
<td>{formatPrice(trade.amount, trade.currency, false, false)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
1
src/routes/markets/+page.svelte
Normal file
1
src/routes/markets/+page.svelte
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Under construction
|
1
src/routes/trades/+page.svelte
Normal file
1
src/routes/trades/+page.svelte
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Under construction
|
BIN
static/haveno_logo_icon.png
Normal file
BIN
static/haveno_logo_icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
BIN
static/monero-symbol-on-white-1280.png
Normal file
BIN
static/monero-symbol-on-white-1280.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import adapter from "svelte-adapter-bun";
|
||||||
|
import preprocess from "svelte-preprocess";
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
},
|
||||||
|
preprocess: [preprocess()],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
6
vite.config.js
Normal file
6
vite.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
});
|
Loading…
Reference in a new issue