--- /dev/null
+import "./server.js";
--- /dev/null
+import mysql from "mysql2/promise";
+
+export const db = mysql.createPool({
+ host: "localhost",
+ user: "root",
+ password: "vava",
+ database: "crypto",
+ port: 3306
+});
--- /dev/null
+<!doctype html>
+<html lang="fr">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>Val Crypto - Prix</title>
+ <style>
+ body { font-family: Arial, sans-serif; padding: 20px; }
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; margin-top: 16px; }
+ .card { border: 1px solid #ddd; border-radius: 10px; padding: 14px; }
+ .title { font-weight: 700; font-size: 18px; margin-bottom: 6px; }
+ .price { font-size: 28px; font-weight: 800; margin: 10px 0; }
+ .meta { color: #555; font-size: 13px; }
+ button { padding: 8px 12px; border-radius: 8px; border: 1px solid #ccc; cursor: pointer; }
+ .row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
+ .err { color: #b00020; font-weight: 600; }
+ code { background: #f5f5f5; padding: 2px 5px; border-radius: 6px; }
+ </style>
+</head>
+<body>
+ <h1>Val Crypto - Prix (toutes les 5 min)</h1>
+
+ <div class="row">
+ <button id="btnRefresh">Refresh manuel</button>
+ <span id="status" class="meta">—</span>
+ </div>
+
+ <div class="grid" id="grid"></div>
+
+ <p class="meta">
+ API :
+ <code>/api/price/current?pair=BTC/EUR</code> |
+ <code>/api/price/current?pair=BTC/USDT</code>
+ </p>
+
+ <script>
+ const PAIRS = ["BTC/EUR", "BTC/USDT"];
+ const grid = document.getElementById("grid");
+ const statusEl = document.getElementById("status");
+ const btnRefresh = document.getElementById("btnRefresh");
+
+ function fmt(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return "-";
+ if (v >= 1000) return v.toFixed(2);
+ if (v >= 1) return v.toFixed(4);
+ return v.toFixed(6);
+ }
+
+ function nowFR() {
+ return new Date().toLocaleString("fr-FR");
+ }
+
+ async function fetchCurrent(pair) {
+ const res = await fetch(`/api/price/current?pair=${encodeURIComponent(pair)}`);
+ const json = await res.json();
+ return { pair, resOk: res.ok, json };
+ }
+
+ function render(results) {
+ grid.innerHTML = results.map(r => {
+ if (!r.resOk || !r.json.ok) {
+ const msg = r.json?.error?.message || "Erreur API";
+ return `
+ <div class="card">
+ <div class="title">${r.pair}</div>
+ <div class="err">${msg}</div>
+ <div class="meta">${nowFR()}</div>
+ </div>
+ `;
+ }
+ const d = r.json.data;
+ const quote = (d.pair || r.pair).split("/")[1] || "";
+ return `
+ <div class="card">
+ <div class="title">${d.pair}</div>
+ <div class="price">${fmt(d.current_price)} ${quote}</div>
+ <div class="meta">Source: ${d.source}</div>
+ <div class="meta">Horodatage: ${new Date(d.timestamp_ms).toLocaleString("fr-FR")}</div>
+ </div>
+ `;
+ }).join("");
+ }
+
+ async function refresh() {
+ statusEl.textContent = "Chargement...";
+ try {
+ const results = await Promise.all(PAIRS.map(fetchCurrent));
+ render(results);
+ statusEl.textContent = "Dernière mise à jour : " + nowFR();
+ } catch (e) {
+ statusEl.textContent = "Erreur : " + e.message;
+ }
+ }
+
+ btnRefresh.addEventListener("click", async () => {
+ statusEl.textContent = "Refresh manuel...";
+ for (const pair of PAIRS) {
+ await fetch(`/api/price/refresh?pair=${encodeURIComponent(pair)}`, { method: "POST" });
+ }
+ await refresh();
+ });
+
+ refresh();
+ // côté UI on rafraîchit l'affichage toutes les 15s (ça lit juste le dernier point)
+ setInterval(refresh, 15000);
+ </script>
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+import { db } from "../db.js";
+
+/**
+ * Accepte:
+ * - BTC/EUR, BTC_USDT, BTC-USDT, "btc eur"
+ * Et match la DB qui stocke pair_code = BTC_EUR etc.
+ */
+export async function getActivePairIdByCode(pairCode) {
+ const raw = String(pairCode || "").trim().toUpperCase();
+ const normalized = raw.replace(/\s+/g, "").replace(/[-/]/g, "_");
+
+ const [rows] = await db.execute(
+ `
+ SELECT pair_id
+ FROM pairs
+ WHERE pair_code = ?
+ LIMIT 1
+ `,
+ [normalized]
+ );
+
+ if (rows.length === 0) {
+ const err = new Error(`PAIR_NOT_FOUND: ${normalized}`);
+ err.code = "PAIR_NOT_FOUND";
+ throw err;
+ }
+
+ return rows[0].pair_id;
+}
+
+/**
+ * Récupère une paire par son ID.
+ * On récupère pair_code pour reconstruire base/quote sans dépendre de base_symbol/quote_symbol.
+ */
+export async function getPairById(pairId) {
+ const id = Number(pairId);
+
+ const [rows] = await db.execute(
+ `
+ SELECT pair_id, base_symbol, quote_symbol, pair_code
+ FROM pairs
+ WHERE pair_id = ?
+ LIMIT 1
+ `,
+ [id]
+ );
+
+ if (rows.length === 0) {
+ const err = new Error(`PAIR_ID_NOT_FOUND: ${id}`);
+ err.code = "PAIR_ID_NOT_FOUND";
+ throw err;
+ }
+
+ return rows[0];
+}
+export async function listActivePairs() {
+ const [rows] = await db.execute(`
+ SELECT pair_id, base_symbol, quote_symbol, pair_code, is_active
+ FROM pairs
+ WHERE is_active = 1
+ ORDER BY pair_id ASC
+ `);
+ return rows;
+}
+
+export async function listAllPairs() {
+ const [rows] = await db.execute(
+ `
+ SELECT pair_id, base_symbol, quote_symbol, pair_code, is_active
+ FROM pairs
+ ORDER BY pair_id ASC
+ `
+ );
+ return rows;
+}
\ No newline at end of file
--- /dev/null
+import { db } from "../db.js";
+import crypto from "crypto";
+
+/**
+ * Insère / met à jour une BOUGIE OHLC (5 minutes) dans la table existante.
+ * - interval_sec = 300
+ * - timestamp_ms = début du bucket (arrondi)
+ * - open = premier prix du bucket
+ * - high/low = extrêmes du bucket
+ * - close = dernier prix du bucket
+ *
+ * ⚠️ Pour que high/low varient, il faut appeler insertPricePoint
+ * plus souvent que toutes les 5 minutes (ex: toutes les 10–30s).
+ */
+export async function insertPricePoint({
+ pair_id,
+ timestamp_ms,
+ current_price,
+ source,
+ volume_24h = null,
+ candle_close = null
+}) {
+ const pid = Number(pair_id);
+ const ts = Number(timestamp_ms);
+ const price = Number(current_price);
+ const src = String(source || "").toUpperCase().trim();
+
+ if (!Number.isFinite(pid)) throw new Error("pair_id invalide");
+ if (!Number.isFinite(ts)) throw new Error("timestamp_ms invalide");
+ if (!Number.isFinite(price) || price <= 0) throw new Error("current_price invalide (>0)");
+ if (!src) throw new Error("source obligatoire");
+
+ const intervalSec = 300; // 5 minutes
+
+ // ✅ bucket start (début de la bougie)
+ const bucketStart = Math.floor(ts / (intervalSec * 1000)) * (intervalSec * 1000);
+ const bucketEnd = bucketStart + (intervalSec * 1000) - 1;
+
+ const closeValue = candle_close != null ? Number(candle_close) : price;
+ if (!Number.isFinite(closeValue) || closeValue <= 0) {
+ throw new Error("candle_close invalide (>0)");
+ }
+
+ const vol = volume_24h == null ? null : Number(volume_24h);
+ if (vol != null && (!Number.isFinite(vol) || vol < 0)) {
+ throw new Error("volume invalide (>=0)");
+ }
+
+ // ✅ Cherche une bougie existante DANS le bucket (même si timestamp_ms n'est pas arrondi)
+ const [existing] = await db.execute(
+ `
+ SELECT price_id, open_price, high_price, low_price, close_price, timestamp_ms
+ FROM price_points
+ WHERE pair_id = ?
+ AND source = ?
+ AND interval_sec = ?
+ AND timestamp_ms BETWEEN ? AND ?
+ ORDER BY timestamp_ms ASC
+ LIMIT 1
+ `,
+ [pid, src, intervalSec, bucketStart, bucketEnd]
+ );
+
+ // 2) Si pas de bougie -> on crée une nouvelle
+ if (existing.length === 0) {
+ const priceId = crypto.randomUUID();
+
+ await db.execute(
+ `
+ INSERT INTO price_points (
+ price_id,
+ pair_id,
+ source,
+ interval_sec,
+ timestamp_ms,
+ open_price,
+ high_price,
+ low_price,
+ close_price,
+ volume,
+ created_at_ms
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ [
+ priceId,
+ pid,
+ src,
+ intervalSec,
+ bucketStart,
+ price, // open
+ price, // high
+ price, // low
+ closeValue, // close
+ vol,
+ Date.now()
+ ]
+ );
+ return;
+ }
+
+ // 3) Sinon -> update OHLC (open ne change pas)
+ const row = existing[0];
+ const prevHigh = Number(row.high_price);
+ const prevLow = Number(row.low_price);
+
+ const newHigh = Number.isFinite(prevHigh) ? Math.max(prevHigh, price) : price;
+ const newLow = Number.isFinite(prevLow) ? Math.min(prevLow, price) : price;
+
+ await db.execute(
+ `
+ UPDATE price_points
+ SET timestamp_ms = ?, -- ✅ on "recalle" la bougie sur le bucketStart
+ high_price = ?,
+ low_price = ?,
+ close_price = ?,
+ volume = ?,
+ created_at_ms = ?
+ WHERE price_id = ?
+ `,
+ [bucketStart, newHigh, newLow, closeValue, vol, Date.now(), row.price_id]
+ );
+}
+
+/**
+ * Prix courant = dernière bougie (close_price)
+ */
+export async function getCurrentPrice(pair_id) {
+ const pid = Number(pair_id);
+
+ const [rows] = await db.execute(
+ `
+ SELECT
+ source,
+ timestamp_ms,
+ open_price,
+ high_price,
+ low_price,
+ close_price,
+ volume
+ FROM price_points
+ WHERE pair_id = ?
+ ORDER BY timestamp_ms DESC, created_at_ms DESC
+ LIMIT 1
+ `,
+ [pid]
+ );
+
+ const r = rows[0];
+ if (!r) return null;
+
+ return {
+ source: r.source,
+ timestamp_ms: r.timestamp_ms,
+ current_price: r.close_price,
+ open_price: r.open_price,
+ high_price: r.high_price,
+ low_price: r.low_price,
+ close_price: r.close_price,
+ volume_24h: r.volume ?? null,
+ candle_close: r.close_price
+ };
+}
+
+/**
+ * Historique = dernières bougies (OHLC)
+ */
+export async function getPriceHistory(pair_id, limit = 200) {
+ const pid = Number(pair_id);
+ const lim = Math.max(1, Math.min(2000, Number(limit) || 200));
+
+ const [rows] = await db.execute(
+ `
+ SELECT
+ source,
+ timestamp_ms,
+ open_price,
+ high_price,
+ low_price,
+ close_price,
+ volume
+ FROM price_points
+ WHERE pair_id = ?
+ ORDER BY timestamp_ms DESC, created_at_ms DESC
+ LIMIT ?
+ `,
+ [pid, lim]
+ );
+
+ return rows.reverse().map((r) => ({
+ source: r.source,
+ timestamp_ms: r.timestamp_ms,
+ current_price: r.close_price,
+ open_price: r.open_price,
+ high_price: r.high_price,
+ low_price: r.low_price,
+ close_price: r.close_price,
+ volume_24h: r.volume ?? null,
+ candle_close: r.close_price
+ }));
+}
\ No newline at end of file
--- /dev/null
+import express from "express";
+import { db } from "./db.js";
+
+import { formatProviderError, getPriceWithFallback } from "./services/price.service.js";
+import { getActivePairIdByCode, getPairById, listActivePairs } from "./repositories/pair.repository.js";
+import { insertPricePoint, getCurrentPrice, getPriceHistory } from "./repositories/price.repository.js";
+
+const app = express();
+const PORT = 3000;
+
+// On fetch souvent pour construire OHLC (bougie 5 min)
+const SAMPLE_INTERVAL_MS = 10 * 1000; // 10s
+
+// Toutes les paires actives (chargées au démarrage)
+let PAIR_IDS_TO_SAMPLE = [];
+
+app.use(express.json());
+app.use(express.static("public"));
+
+function ok(res, data) {
+ return res.json({ ok: true, data });
+}
+
+function fail(res, status, code, message, details = null) {
+ return res.status(status).json({
+ ok: false,
+ error: { code, message, details }
+ });
+}
+
+function normalizePairCode(value) {
+ return String(value || "").trim().toUpperCase();
+}
+
+/**
+ * Convertit:
+ * - BTC/EUR, BTC_EUR, BTC-EUR, "BTC EUR"
+ * en { base:"BTC", quote:"EUR", dbCode:"BTC_EUR" }
+ */
+function parsePairInput(pairInput) {
+ const raw = normalizePairCode(pairInput);
+ const dbCode = raw.replace(/\s+/g, "").replace(/[-/]/g, "_");
+ const parts = dbCode.split("_");
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
+ const err = new Error("PAIR_INVALID");
+ err.code = "PAIR_INVALID";
+ throw err;
+ }
+ return { base: parts[0], quote: parts[1], dbCode };
+}
+
+/**
+ * Reconstruit base/quote depuis pair_code DB (ex: BTC_EUR)
+ * -> { base:"BTC", quote:"EUR" }
+ */
+function parsePairCodeFromDb(pairCodeDb) {
+ const code = String(pairCodeDb || "").trim().toUpperCase();
+ const parts = code.split("_");
+ if (parts.length !== 2) throw new Error(`PAIR_CODE_DB_INVALID: ${code}`);
+ return { base: parts[0], quote: parts[1] };
+}
+
+/**
+ * Charge toutes les paires actives (is_active=1) depuis la DB
+ */
+async function loadAllActivePairIds() {
+ const pairs = await listActivePairs(); // doit être ORDER BY pair_id ASC dans le repo
+ PAIR_IDS_TO_SAMPLE = pairs.map((p) => p.pair_id);
+ console.log("PAIR_IDS_TO_SAMPLE (actives):", PAIR_IDS_TO_SAMPLE);
+}
+
+/**
+ * ✅ Liste des paires (pour ton interface)
+ * GET /api/pairs
+ */
+app.get("/api/pairs", async (req, res) => {
+ try {
+ const pairs = await listActivePairs();
+ return ok(res, { count: pairs.length, pairs });
+ } catch (e) {
+ return fail(res, 500, "DB_ERROR", "Erreur lecture des paires", e.message);
+ }
+});
+
+/**
+ * DEBUG: Voir les paires en DB (tout)
+ * GET /api/debug/pairs
+ */
+app.get("/api/debug/pairs", async (req, res) => {
+ try {
+ const [rows] = await db.execute(
+ "SELECT pair_id, base_symbol, quote_symbol, pair_code, is_active FROM pairs ORDER BY pair_id"
+ );
+ return ok(res, { count: rows.length, rows });
+ } catch (e) {
+ return fail(res, 500, "DB_ERROR", "Erreur lecture table pairs", e.message);
+ }
+});
+
+/**
+ * Fetch + insert sur une paire ID
+ */
+async function fetchAndInsertTickById(pairId, preferredSource = "BINANCE") {
+ const pairRow = await getPairById(pairId);
+ const { base, quote } = parsePairCodeFromDb(pairRow.pair_code);
+
+ const data = await getPriceWithFallback(preferredSource, { base, quote });
+
+ await insertPricePoint({
+ pair_id: pairRow.pair_id,
+ timestamp_ms: data.timestamp_ms,
+ current_price: data.price,
+ source: data.source
+ });
+
+ return {
+ pair_id: pairRow.pair_id,
+ pair_code: pairRow.pair_code,
+ base,
+ quote,
+ source: data.source,
+ timestamp_ms: data.timestamp_ms,
+ current_price: data.price,
+ used_fallback: Boolean(data.used_fallback)
+ };
+}
+
+/**
+ * Prix courant
+ * GET /api/price/current?pair=BTC/EUR
+ */
+app.get("/api/price/current", async (req, res) => {
+ const pair = normalizePairCode(req.query.pair);
+
+ if (!pair) {
+ return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)");
+ }
+
+ let pair_id;
+ try {
+ pair_id = await getActivePairIdByCode(pair);
+ } catch (err) {
+ if (err.code === "PAIR_NOT_FOUND") {
+ return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`);
+ }
+ return fail(res, 400, "PAIR_INVALID", `Paire invalide: ${pair}`);
+ }
+
+ try {
+ const row = await getCurrentPrice(pair_id);
+ if (!row) {
+ return fail(res, 404, "NO_PRICE", `Aucun prix en base pour ${pair}`);
+ }
+
+ return ok(res, {
+ pair,
+ pair_id,
+ source: row.source,
+ timestamp_ms: row.timestamp_ms,
+ current_price: row.current_price,
+ open_price: row.open_price,
+ high_price: row.high_price,
+ low_price: row.low_price,
+ close_price: row.close_price,
+ volume_24h: row.volume_24h ?? null
+ });
+ } catch (err) {
+ return fail(res, 500, "DB_ERROR", "Erreur lecture prix courant", err.message);
+ }
+});
+
+/**
+ * Historique
+ * GET /api/price/history?pair=BTC/EUR&limit=200
+ */
+app.get("/api/price/history", async (req, res) => {
+ const pair = normalizePairCode(req.query.pair);
+ const limit = req.query.limit;
+
+ if (!pair) {
+ return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)");
+ }
+
+ let pair_id;
+ try {
+ pair_id = await getActivePairIdByCode(pair);
+ } catch (err) {
+ if (err.code === "PAIR_NOT_FOUND") {
+ return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`);
+ }
+ return fail(res, 400, "PAIR_INVALID", `Paire invalide: ${pair}`);
+ }
+
+ try {
+ const points = await getPriceHistory(pair_id, limit);
+ return ok(res, { pair, pair_id, points });
+ } catch (err) {
+ return fail(res, 500, "DB_ERROR", "Erreur lecture historique", err.message);
+ }
+});
+
+/**
+ * Refresh manuel
+ * POST /api/price/refresh?pair=BTC/EUR
+ */
+app.post("/api/price/refresh", async (req, res) => {
+ const pair = normalizePairCode(req.query.pair);
+ if (!pair) {
+ return fail(res, 400, "PAIR_REQUIRED", "Parametre 'pair' obligatoire (ex: BTC/EUR)");
+ }
+
+ try {
+ const { base, quote } = parsePairInput(pair);
+ const pair_id = await getActivePairIdByCode(pair);
+
+ const data = await getPriceWithFallback("BINANCE", { base, quote });
+
+ await insertPricePoint({
+ pair_id,
+ timestamp_ms: data.timestamp_ms,
+ current_price: data.price,
+ source: data.source
+ });
+
+ return ok(res, {
+ pair,
+ pair_id,
+ source: data.source,
+ timestamp_ms: data.timestamp_ms,
+ current_price: data.price,
+ used_fallback: Boolean(data.used_fallback)
+ });
+ } catch (err) {
+ if (err.code === "PAIR_NOT_FOUND") {
+ return fail(res, 404, "PAIR_NOT_FOUND", `Paire inconnue: ${pair}`);
+ }
+ return fail(res, 503, "PROVIDER_ERROR", `Refresh impossible pour ${pair}`, formatProviderError(err));
+ }
+});
+
+/**
+ * Scheduler: toutes les paires actives à chaque tick
+ * => limite de concurrence pour éviter rate-limit
+ */
+async function schedulerTick() {
+ if (PAIR_IDS_TO_SAMPLE.length === 0) {
+ console.warn("Aucune paire active à sampler (PAIR_IDS_TO_SAMPLE vide).");
+ return;
+ }
+
+ const preferred = "BINANCE";
+ const CONCURRENCY = 5; // ajuste à 3/5/10 selon le nombre de paires
+
+ let i = 0;
+
+ async function worker() {
+ while (i < PAIR_IDS_TO_SAMPLE.length) {
+ const pairId = PAIR_IDS_TO_SAMPLE[i++];
+ try {
+ const tick = await fetchAndInsertTickById(pairId, preferred);
+ console.log(
+ `Tick OK pair_id=${tick.pair_id} (${tick.pair_code}) ${tick.current_price} (${tick.source}${tick.used_fallback ? " fallback" : ""})`
+ );
+ } catch (err) {
+ console.error(`Erreur scheduler pair_id=${pairId}: ${formatProviderError(err)}`);
+ }
+ }
+ }
+
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
+}
+
+/**
+ * ✅ Démarrage du serveur
+ */
+app.listen(PORT, async () => {
+ console.log(`Serveur lance : http://localhost:${PORT}`);
+
+ try {
+ await loadAllActivePairIds(); // charge toutes les paires actives
+ await schedulerTick(); // 1 tick immédiat
+ setInterval(schedulerTick, SAMPLE_INTERVAL_MS);
+ } catch (e) {
+ console.error("Erreur démarrage serveur:", e.message);
+ }
+});
\ No newline at end of file
--- /dev/null
+import axios from "axios";
+
+function normalizePair(pair = {}) {
+ return {
+ base: String(pair.base || "BTC").toUpperCase(),
+ quote: String(pair.quote || "EUR").toUpperCase()
+ };
+}
+
+export async function getBinancePrice(pair) {
+ const normalizedPair = normalizePair(pair);
+
+ const res = await axios.get(
+ "https://api.binance.com/api/v3/ticker/price",
+ {
+ timeout: 5000,
+ params: {
+ symbol: `${normalizedPair.base}${normalizedPair.quote}`
+ }
+ }
+ );
+
+ return {
+ price: parseFloat(res.data.price),
+ timestamp_ms: Date.now()
+ };
+}
--- /dev/null
+const COINGECKO_SYMBOL_TO_ID = {
+ BTC: "bitcoin",
+ ETH: "ethereum",
+ BNB: "binancecoin",
+ ADA: "cardano",
+ DOGE: "dogecoin",
+ LTC: "litecoin"
+};
+
+function normalizePair(pair = {}) {
+ return {
+ base: String(pair.base || "BTC").toUpperCase(),
+ quote: String(pair.quote || "EUR").toUpperCase()
+ };
+}
+
+export async function getCoinGeckoPrice(pair) {
+ const { base, quote } = normalizePair(pair);
+
+ const vs = quote.toLowerCase();
+ const id = COINGECKO_SYMBOL_TO_ID[base];
+ if (!id) throw new Error(`CoinGecko: symbole non supporte: ${base}`);
+
+ const url =
+ `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(id)}` +
+ `&vs_currencies=${encodeURIComponent(vs)}`;
+
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`);
+
+ const json = await res.json();
+ const price = Number(json?.[id]?.[vs]);
+ if (!Number.isFinite(price)) throw new Error("Prix CoinGecko invalide");
+
+ return {
+ price,
+ timestamp_ms: Date.now()
+ };
+}
\ No newline at end of file
--- /dev/null
+import { getCoinGeckoPrice } from "./coingecko.service.js";
+import { getBinancePrice } from "./binance.service.js";
+
+const PRICE_FETCHERS = {
+ COINGECKO: getCoinGeckoPrice,
+ BINANCE: getBinancePrice
+};
+
+export async function getPriceBySource(source, pair) {
+ const normalizedSource = String(source || "").toUpperCase();
+ const fetcher = PRICE_FETCHERS[normalizedSource];
+
+ if (!fetcher) {
+ throw new Error(`Source inconnue: ${source}`);
+ }
+
+ const data = await fetcher(pair);
+ const price = Number(data.price);
+
+ if (!Number.isFinite(price)) {
+ throw new Error(`Prix invalide recu depuis ${normalizedSource}`);
+ }
+
+ return {
+ source: normalizedSource,
+ price,
+ timestamp_ms: Number(data.timestamp_ms) || Date.now()
+ };
+}
+
+export function getOtherSource(source) {
+ const normalizedSource = String(source || "").toUpperCase();
+ if (normalizedSource === "COINGECKO") {
+ return "BINANCE";
+ }
+ if (normalizedSource === "BINANCE") {
+ return "COINGECKO";
+ }
+ throw new Error(`Source inconnue: ${source}`);
+}
+
+export function formatProviderError(err) {
+ if (!err) {
+ return "erreur inconnue";
+ }
+
+ const status = err.response?.status;
+ const statusText = err.response?.statusText;
+ const code = err.code;
+ const message = err.message || "erreur sans message";
+
+ return [status ? `HTTP ${status}` : null, statusText, code, message]
+ .filter(Boolean)
+ .join(" | ");
+}
+
+export async function getPriceWithFallback(primarySource, pair) {
+ const normalizedPrimary = String(primarySource || "").toUpperCase();
+ const fallbackSource = getOtherSource(normalizedPrimary);
+
+ try {
+ return await getPriceBySource(normalizedPrimary, pair);
+ } catch (primaryError) {
+ try {
+ const fallbackData = await getPriceBySource(fallbackSource, pair);
+ return {
+ ...fallbackData,
+ used_fallback: true,
+ intended_source: normalizedPrimary
+ };
+ } catch (fallbackError) {
+ const error = new Error(
+ `Primary(${normalizedPrimary})=${formatProviderError(
+ primaryError
+ )} | Fallback(${fallbackSource})=${formatProviderError(fallbackError)}`
+ );
+ error.primaryError = primaryError;
+ error.fallbackError = fallbackError;
+ throw error;
+ }
+ }
+}
+
+let useCoinGeckoNext = true;
+
+export async function getAlternatingPrice(pair) {
+ const orderedSources = useCoinGeckoNext
+ ? ["COINGECKO", "BINANCE"]
+ : ["BINANCE", "COINGECKO"];
+
+ useCoinGeckoNext = !useCoinGeckoNext;
+
+ let lastError = null;
+
+ for (const source of orderedSources) {
+ try {
+ return await getPriceBySource(source, pair);
+ } catch (err) {
+ lastError = err;
+ console.log(`${source} indisponible, tentative suivante...`);
+ }
+ }
+
+ throw lastError ?? new Error("Aucune API disponible");
+}
--- /dev/null
+import { getAlternatingPrice } from "./services/price.service.js";
+import { insertCandle } from "./repositories/price.repository.js";
+import { getActivePairId } from "./repositories/pair.repository.js";
+import { db } from "./db.js";
+
+const SAMPLE_COUNT = 12;
+const SAMPLE_DELAY_MS = 5000;
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function testDbCommunication() {
+ try {
+ console.log("TEST COMMUNICATION API -> DB (OHLC reel)");
+
+ const prices = [];
+ let lastSource = "UNKNOWN";
+
+ for (let i = 0; i < SAMPLE_COUNT; i += 1) {
+ const data = await getAlternatingPrice();
+ prices.push(Number(data.price));
+ lastSource = data.source;
+ console.log(
+ `Sample ${i + 1}/${SAMPLE_COUNT}: ${data.price} via ${data.source}`
+ );
+ if (i < SAMPLE_COUNT - 1) {
+ await sleep(SAMPLE_DELAY_MS);
+ }
+ }
+
+ const open = prices[0];
+ const high = Math.max(...prices);
+ const low = Math.min(...prices);
+ const close = prices[prices.length - 1];
+
+ const pairId = await getActivePairId("BTC", "EUR");
+
+ await insertCandle({
+ pair_id: pairId,
+ source: lastSource,
+ interval_sec: 300,
+ timestamp_ms: Date.now(),
+ open_price: open,
+ high_price: high,
+ low_price: low,
+ close_price: close
+ });
+
+ const [rows] = await db.query(`
+ SELECT
+ FROM_UNIXTIME(timestamp_ms / 1000) AS candle_time,
+ source,
+ interval_sec,
+ open_price,
+ high_price,
+ low_price,
+ close_price
+ FROM price_points
+ WHERE interval_sec = 300
+ ORDER BY created_at_ms DESC
+ LIMIT 1
+ `);
+
+ console.log("DONNEE EN BASE :", rows[0]);
+ process.exit(0);
+ } catch (err) {
+ console.error("TEST ECHOUE :", err);
+ process.exit(1);
+ }
+}
+
+testDbCommunication();