]> git.digitality.be Git - pdw25-26/commitdiff
Ajout du dossier Val_Crypto
authorValentin Hulin <e23743@eps-marche.be>
Tue, 24 Feb 2026 07:49:00 +0000 (08:49 +0100)
committerValentin Hulin <e23743@eps-marche.be>
Tue, 24 Feb 2026 07:49:00 +0000 (08:49 +0100)
server/Val_Crypto/app.js [new file with mode: 0644]
server/Val_Crypto/db.js [new file with mode: 0644]
server/Val_Crypto/public/index.html [new file with mode: 0644]
server/Val_Crypto/repositories/pair.repository.js [new file with mode: 0644]
server/Val_Crypto/repositories/price.repository.js [new file with mode: 0644]
server/Val_Crypto/server.js [new file with mode: 0644]
server/Val_Crypto/services/binance.service.js [new file with mode: 0644]
server/Val_Crypto/services/coingecko.service.js [new file with mode: 0644]
server/Val_Crypto/services/price.service.js [new file with mode: 0644]
server/Val_Crypto/test-db.js [new file with mode: 0644]

diff --git a/server/Val_Crypto/app.js b/server/Val_Crypto/app.js
new file mode 100644 (file)
index 0000000..4bb257a
--- /dev/null
@@ -0,0 +1 @@
+import "./server.js";
diff --git a/server/Val_Crypto/db.js b/server/Val_Crypto/db.js
new file mode 100644 (file)
index 0000000..ee443a3
--- /dev/null
@@ -0,0 +1,9 @@
+import mysql from "mysql2/promise";
+
+export const db = mysql.createPool({
+  host: "localhost",
+  user: "root",
+  password: "vava",
+  database: "crypto",
+  port: 3306
+});
diff --git a/server/Val_Crypto/public/index.html b/server/Val_Crypto/public/index.html
new file mode 100644 (file)
index 0000000..b021b5f
--- /dev/null
@@ -0,0 +1,109 @@
+<!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
diff --git a/server/Val_Crypto/repositories/pair.repository.js b/server/Val_Crypto/repositories/pair.repository.js
new file mode 100644 (file)
index 0000000..484251b
--- /dev/null
@@ -0,0 +1,75 @@
+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
diff --git a/server/Val_Crypto/repositories/price.repository.js b/server/Val_Crypto/repositories/price.repository.js
new file mode 100644 (file)
index 0000000..6dec8cb
--- /dev/null
@@ -0,0 +1,201 @@
+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
diff --git a/server/Val_Crypto/server.js b/server/Val_Crypto/server.js
new file mode 100644 (file)
index 0000000..f652eec
--- /dev/null
@@ -0,0 +1,286 @@
+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
diff --git a/server/Val_Crypto/services/binance.service.js b/server/Val_Crypto/services/binance.service.js
new file mode 100644 (file)
index 0000000..f1cce31
--- /dev/null
@@ -0,0 +1,27 @@
+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()
+  };
+}
diff --git a/server/Val_Crypto/services/coingecko.service.js b/server/Val_Crypto/services/coingecko.service.js
new file mode 100644 (file)
index 0000000..0befa88
--- /dev/null
@@ -0,0 +1,39 @@
+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
diff --git a/server/Val_Crypto/services/price.service.js b/server/Val_Crypto/services/price.service.js
new file mode 100644 (file)
index 0000000..0e4cd4d
--- /dev/null
@@ -0,0 +1,105 @@
+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");
+}
diff --git a/server/Val_Crypto/test-db.js b/server/Val_Crypto/test-db.js
new file mode 100644 (file)
index 0000000..2d40658
--- /dev/null
@@ -0,0 +1,73 @@
+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();