Repository: dcapal/dcapal (language: rust)

Issue:
Replace CW with K for Crypto markets discovery
### Overview

As part of [CW sunsetting program](https://blog.kraken.com/product/cryptowatch-to-sunset-kraken-pro-to-integrate-cryptowatch-features), REST APIs will be discontinued. While DcaPal leverages YF for prices nowadays, Crypto and FX markets discovery is done via CW REST APIs. 

We should implement a new `adapter` for K REST APIs to replace CW


Discussion / hints:
Hi Leo,

Try one of these:

| **API** | **Example**                                                 |
|--------------|----------------------------------------------------------------|
| Binance      | https://data.binance.com/api/v3/ticker/24hr                    |
| CoinBase     | https://api.coinbase.com/v2/exchange-rates?currency=BTC        |
| CoinCap      | https://api.coincap.io/v2/assets                               |
| CoinGecko    | https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd |
| CoinLore     | https://api.coinlore.net/api/tickers/                          |
| CoinPaprika  | https://api.coinpaprika.com/v1/tickers                         |
| CoinStats    | https://api.coinstats.app/public/v1/markets?coinId=bitcoin     |
| DEX Screener | https://api.dexscreener.com/latest/dex/search?q=WBNB%20USDC    |
| Exchangerate | https://api.exchangerate.host/latest                           |
| Gemini       | https://api.gemini.com/v2/ticker/btcusd                        |
| Kraken       | https://api.kraken.com/0/public/Trades?pair=ltcusd             |
| WazirX       | https://api.wazirx.com/sapi/v1/tickers/24hr                    |

Hey Corrado, yeah I was thinking to stick with Kraken's markets as I trust their judgement in listing coins. Moreover, it offers both Crypto and FX markets which is convenient.

I was planning to use:

- `/AssetPairs` endpoint for market discovery: https://docs.kraken.com/rest/#tag/Market-Data/operation/getTradableAssetPairs
- `/OHLC` endpoint for 5-minutes prices: https://docs.kraken.com/rest/#tag/Market-Data/operation/getOHLCData
Optimal! Thank you

Fix patch:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index a5a3e841..2f25114c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -100,8 +100,8 @@ jobs:
               image: redis/redis-stack-server
               container_name: redis
               restart: always
-              expose:
-                - 6379
+              ports:
+                - ${REDIS_PORT}:6379
               networks:
                 - dcapalnetwork
               volumes:
@@ -164,18 +164,19 @@ jobs:
         env:
           DCAPAL_CW_API_KEY: ${{ secrets.DCAPAL_CW_API_KEY }}
           DCAPAL_IP_API_KEY: ${{ secrets.DCAPAL_IP_API_KEY }}
+          DCAPAL_CMC_API_KEY: ${{ secrets.DCAPAL_CMC_API_KEY }}
           DCAPAL_API_PORT: ${{ secrets.DCAPAL_API_PORT }}
           DCAPAL_METRICS_PORT: ${{ secrets.DCAPAL_METRICS_PORT }}
-          REDIS_PORT: ${{ secrets.REDIS_PORT }}
           REDIS_USER: ${{ secrets.REDIS_USER }}
           REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
         run: |
           cat > dcapal-backend/dcapal.yml << EOF
           app:
             providers:
-              priceProvider: yahoo
+              priceProvider: kraken
               cwApiKey: ${DCAPAL_CW_API_KEY}
               ipApiKey: ${DCAPAL_IP_API_KEY}
+              cmcApiKey: ${DCAPAL_CMC_API_KEY}
 
             log:
               level: dcapal_backend=info,tower_http=debug
@@ -191,7 +192,7 @@ jobs:
               port: ${DCAPAL_METRICS_PORT}
             redis:
               hostname: redis
-              port: ${REDIS_PORT}
+              port: 6379
               user: ${REDIS_USER}
               password: ${REDIS_PASSWORD}
           EOF
@@ -223,4 +224,5 @@ jobs:
             mkdir -p ./data/dcapal
             touch ./data/dcapal/dcapal.log
             docker compose -f docker-compose.yml -f ./docker/docker-compose.prod.yml pull
+            docker compose -f docker-compose.yml -f ./docker/docker-compose.prod.yml down
             docker compose -f docker-compose.yml -f ./docker/docker-compose.prod.yml up -d
diff --git a/Cargo.lock b/Cargo.lock
index 990e4a8b..efd72c30 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -41,9 +41,9 @@ dependencies = [
 
 [[package]]
 name = "aho-corasick"
-version = "0.7.20"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
+checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783"
 dependencies = [
  "memchr",
 ]
@@ -551,13 +551,16 @@ dependencies = [
  "const_format",
  "dashmap",
  "deadpool-redis",
+ "failsafe",
  "futures",
  "hyper",
+ "itertools",
  "lazy_static",
  "metrics",
  "metrics-exporter-prometheus",
  "parking_lot",
  "redis",
+ "regex",
  "reqwest",
  "serde",
  "serde_json",
@@ -651,6 +654,12 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
 
+[[package]]
+name = "either"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
 [[package]]
 name = "encoding_rs"
 version = "0.8.31"
@@ -694,6 +703,18 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "failsafe"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60888d0f510699b3ef8734a045b0d28f4279283d7665acacc68cd7e8ac18e683"
+dependencies = [
+ "futures-core",
+ "parking_lot",
+ "pin-project",
+ "rand",
+]
+
 [[package]]
 name = "fastrand"
 version = "1.8.0"
@@ -1127,6 +1148,15 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.6"
@@ -1220,7 +1250,7 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
 dependencies = [
- "regex-automata",
+ "regex-automata 0.1.10",
 ]
 
 [[package]]
@@ -1240,9 +1270,9 @@ dependencies = [
 
 [[package]]
 name = "memchr"
-version = "2.5.0"
+version = "2.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
 
 [[package]]
 name = "memoffset"
@@ -1818,13 +1848,14 @@ dependencies = [
 
 [[package]]
 name = "regex"
-version = "1.7.0"
+version = "1.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
+checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47"
 dependencies = [
  "aho-corasick",
  "memchr",
- "regex-syntax",
+ "regex-automata 0.3.8",
+ "regex-syntax 0.7.5",
 ]
 
 [[package]]
@@ -1833,7 +1864,18 @@ version = "0.1.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
 dependencies = [
- "regex-syntax",
+ "regex-syntax 0.6.28",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax 0.7.5",
 ]
 
 [[package]]
@@ -1842,6 +1884,12 @@ version = "0.6.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
 
+[[package]]
+name = "regex-syntax"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
+
 [[package]]
 name = "remove_dir_all"
 version = "0.5.3"
diff --git a/Cargo.toml b/Cargo.toml
index e04e346a..c3eb6fa3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,8 @@ members = ["dcapal-backend", "dcapal-optimizer-wasm"]
 [workspace.package]
 version = "0.3.1"
 authors = ["Leonardo Arcari <leonardo1.arcari@gmail.com>"]
+edition = "2021"
+rust-version = "1.72"
 
 [profile.release-with-debug]
 inherits = "release"
diff --git a/dcapal-backend/Cargo.toml b/dcapal-backend/Cargo.toml
index cb381fb0..5d72a662 100644
--- a/dcapal-backend/Cargo.toml
+++ b/dcapal-backend/Cargo.toml
@@ -2,7 +2,8 @@
 name = "dcapal-backend"
 version.workspace = true
 authors.workspace = true
-edition = "2021"
+edition.workspace = true
+rust-version.workspace = true
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
@@ -34,3 +35,6 @@ dashmap = "5.5.3"
 metrics = "0.21.1"
 metrics-exporter-prometheus = "0.12.1"
 tracing-appender = "0.2.2"
+regex = "1.9.5"
+itertools = "0.11.0"
+failsafe = "1.2.0"
diff --git a/dcapal-backend/docker-compose.yml b/dcapal-backend/docker-compose.yml
index 3e3c2aa8..76ca6f0e 100644
--- a/dcapal-backend/docker-compose.yml
+++ b/dcapal-backend/docker-compose.yml
@@ -4,8 +4,8 @@ services:
     image: redis/redis-stack-server:7.2.0-v0
     container_name: redis
     restart: always
-    expose:
-      - 6379
+    ports:
+      - 50125:6379
     networks:
       - dcapalnetwork
     volumes:
diff --git a/dcapal-backend/src/adapter/kraken.rs b/dcapal-backend/src/adapter/kraken.rs
new file mode 100644
index 00000000..e102df3e
--- /dev/null
+++ b/dcapal-backend/src/adapter/kraken.rs
@@ -0,0 +1,619 @@
+use failsafe::futures::CircuitBreaker;
+use futures::StreamExt;
+use hyper::StatusCode;
+use itertools::Itertools;
+use lazy_static::lazy_static;
+use serde::{de::DeserializeOwned, Deserialize};
+use std::{
+    collections::{BTreeMap, HashMap, HashSet},
+    fmt::Debug,
+};
+use tracing::{debug, error, warn};
+
+use crate::{
+    config,
+    domain::entity::{Asset, AssetId, Crypto, Fiat, Market, MarketId, OHLCFrequency},
+    error::{DcaError, Result},
+    repository::market_data::MarketDataRepository,
+    DateTime,
+};
+
+use super::DefaultCircuitBreaker;
+
+#[derive(Clone)]
+pub struct KrakenProvider {
+    http: reqwest::Client,
+    cmc_api_key: Option<String>,
+    kraken_circuit_breaker: DefaultCircuitBreaker,
+    cmc_circuit_breaker: DefaultCircuitBreaker,
+}
+
+impl KrakenProvider {
+    pub fn new(http: reqwest::Client, config: &config::Providers) -> Self {
+        let kraken_circuit_breaker = failsafe::Config::new().build();
+        let cmc_circuit_breaker = failsafe::Config::new().build();
+
+        Self {
+            http,
+            cmc_api_key: config.cmc_api_key.clone(),
+            kraken_circuit_breaker,
+            cmc_circuit_breaker,
+        }
+    }
+
+    pub async fn fetch_assets(
+        &self,
+        repo: &MarketDataRepository,
+    ) -> Result<(Vec<Asset>, Vec<Market>)> {
+        static URL: &str = "https://api.kraken.com/0/public/AssetPairs";
+
+        // Fetch Kraken markets
+        debug!(url = URL, "Fetching markets from Kraken");
+        let Some(res) = self.fetch_kraken_api::<AssetPairsResponse>(URL).await? else {
+            return Err(DcaError::Generic("AssetPairs not found".to_string()));
+        };
+
+        if !res.error.is_empty() {
+            error!("Error occurred while fetching asset pairs: {:?}", res.error);
+            return Err(DcaError::Generic(format!("{:?}", res.error)));
+        }
+
+        // Filter online market symbols and rename Kraken specific pairs to standard names
+        let market_symbols = res
+            .result
+            .values()
+            .filter_map(|p| (p.status == "online").then(|| normalize_symbol(&p.wsname)))
+            .collect::<Vec<String>>();
+
+        let (markets, assets) = if self.cmc_api_key.is_some() {
+            // If CoinMarketCap API key is available, enrich assets data with human-friendly info
+            self.resolve_assets_data(&market_symbols, repo).await
+        } else {
+            // If not, `Asset`s will have same `id` and `symbol`. No big deal, just less fancy
+            resolve_assets_data_kraken_only(&market_symbols, repo).await
+        };
+
+        debug!("New assets: {}", serde_json::to_string(&assets).unwrap());
+        debug!("New markets: {}", serde_json::to_string(&markets).unwrap());
+
+        Ok((assets, markets))
+    }
+
+    pub async fn fetch_market_price(&self, mkt: &Market, ts: DateTime) -> Result<Option<f64>> {
+        if let Some(px) = self
+            .fetch_price(&mkt.id, OHLCFrequency::Minutes5, ts)
+            .await?
+        {
+            return Ok(Some(px));
+        }
+
+        if let Some(px) = self.fetch_price(&mkt.id, OHLCFrequency::Daily, ts).await? {
+            return Ok(Some(px));
+        }
+
+        Ok(None)
+    }
+
+    async fn fetch_price(
+        &self,
+        id: &MarketId,
+        freq: OHLCFrequency,
+        ts: DateTime,
+    ) -> Result<Option<f64>> {
+        let (r_lo, r_hi) = freq.ohlc_range(ts);
+        let (after_ts, before_ts) = (r_lo.timestamp(), r_hi.timestamp());
+        let periods = get_kraken_api_periods(freq);
+        let url = format!(
+            "https://api.kraken.com/0/public/OHLC?pair={id}&since={after_ts}&interval={periods}"
+        );
+
+        debug!(
+            url = url,
+            "Fetching {freq} OHLC candlestick for market '{id}' since {r_lo}"
+        );
+
+        let Some(mut res) = self.fetch_kraken_api::<OHLCResult>(&url).await? else {
+            return Ok(None);
+        };
+
+        if !res.error.is_empty() {
+            error!("Error occurred while fetching '{id}': {:?}", res.error);
+            return Err(DcaError::Generic(format!("{:?}", res.error)));
+        }
+
+        if res.result.is_empty() || res.result.len() < 2 {
+            return Err(DcaError::Generic(format!(
+                "Malformed response. Unexpected empty result: {res:?}"
+            )));
+        }
+
+        res.result.remove("last"); // Drop fucking weird 'last' entry
+        let Payload::CandleSticks(csticks) = res.result.values().next().unwrap() else {
+            return Err(DcaError::Generic(format!(
+                "Malformed response. Cannot find candlesticks: {res:?}"
+            )));
+        };
+
+        if csticks.0.is_empty() {
+            return Err(DcaError::Generic(format!(
+                "Malformed response. Unexpected empty result: {res:?}"
+            )));
+        }
+
+        // Get most recent candlestick before `before_ts` or most recent
+        let cstick = csticks
+            .0
+            .iter()
+            .rev()
+            .find(|c| c[0].as_i64().unwrap_or(i64::MAX) <= before_ts)
+            .unwrap_or_else(|| csticks.0.iter().last().unwrap());
+
+        let Some(close_px) = cstick[4].as_f64() else {
+            return Err(DcaError::Generic(format!(
+                "Cannot parse '{:?}' into f64",
+                cstick[4]
+            )));
+        };
+
+        Ok(Some(close_px))
+    }
+
+    async fn resolve_assets_data(
+        &self,
+        market_symbols: &[String],
+        repo: &MarketDataRepository,
+    ) -> (Vec<Market>, Vec<Asset>) {
+        let pairs = get_new_markets(market_symbols, repo).await;
+        let ccys = get_unique_ccys(&pairs);
+        let assets_map = self.fetch_assets_cmc(&ccys, repo).await;
+
+        let markets = pairs
+            .into_iter()
+            .filter_map(|(base_id, quote_id)| {
+                let Some(base) = assets_map.get(&base_id) else {
+                    error!("Skipping market {base_id}{quote_id}: base asset data not found");
+                    return None;
+                };
+
+                let Some(quote) = assets_map.get(&quote_id) else {
+                    error!("Skipping market {base_id}{quote_id}: quote asset data not found");
+                    return None;
+                };
+
+                Some(Market::new(
+                    format!("{base_id}{quote_id}"),
+                    base.clone(),
+                    quote.clone(),
+                ))
+            })
+            .collect::<Vec<_>>();
+
+        let assets = assets_map.into_values().collect();
+
+        (markets, assets)
+    }
+
+    async fn fetch_assets_cmc(
+        &self,
+        ccys: &HashSet<AssetId>,
+        repo: &MarketDataRepository,
+    ) -> BTreeMap<String, Asset> {
+        let mut assets = BTreeMap::new();
+        let mut to_fetch = Vec::new();
+        for ccy in ccys {
+            match repo.find_asset(ccy).await {
+                Ok(asset) => {
+                    if let Some(a) = asset {
+                        assets.insert(a.id().clone(), a);
+                    } else if fiat::is_fiat(ccy) {
+                        assets.insert(
+                            ccy.clone(),
+                            Asset::Fiat(Fiat::new(ccy.clone(), fiat::get_name(ccy).to_string())),
+                        );
+                    } else {
+                        to_fetch.push(ccy);
+                    }
+                }
+                Err(e) => {
+                    error!("Failed to find asset '{ccy}': {e}");
+                    continue;
+                }
+            }
+        }
+
+        static CHUNK_SIZE: usize = 100;
+        let mut start = 0;
+        let mut end = CHUNK_SIZE.min(to_fetch.len());
+
+        let next_chunk = |start: &mut usize, end: &mut usize| {
+            *start += CHUNK_SIZE;
+            *end = (*end + CHUNK_SIZE).min(to_fetch.len());
+        };
+
+        while start < end {
+            let chunk = &to_fetch[start..end];
+            let fetched = self.fetch_crypto_cmc(chunk).await;
+
+            if let Err(e) = fetched {
+                warn!("Failed to fetch CMC assets. Filling with default ({chunk:?}): {e:?}");
+
+                for id in chunk {
+                    assets.insert(
+                        id.to_string(),
+                        Asset::Crypto(Crypto::new_with_id(id.to_string())),
+                    );
+                }
+
+                next_chunk(&mut start, &mut end);
+                continue;
+            }
+
+            fetched.unwrap().into_iter().for_each(|a| {
+                assets.insert(a.id().clone(), a);
+            });
+
+            for id in chunk {
+                if !assets.contains_key(*id) {
+                    warn!("Cannot find CMC asset '{id}' in `assets` map. Filling with default");
+
+                    assets.insert(
+                        id.to_string(),
+                        Asset::Crypto(Crypto::new_with_id(id.to_string())),
+                    );
+                }
+            }
+
+            next_chunk(&mut start, &mut end);
+        }
+
+        assets
+    }
+
+    async fn fetch_crypto_cmc(&self, ccys: &[&AssetId]) -> Result<Vec<Asset>> {
+        lazy_static! {
+            static ref CMC_ALIAS: HashMap<&'static str, &'static str> = HashMap::from([
+                ("eth2.s", "eth"),
+                ("repv2", "rep"),
+                ("luna2", "luna"),
+                ("waxl", "axl")
+            ]);
+        }
+
+        // Skip currencies not known by CMC for now. Better to fallback than send confusing asset names
+        let symbols = ccys
+            .iter()
+            .filter_map(|c| (!CMC_ALIAS.contains_key(c.as_str())).then_some(c.as_str()))
+            .join(",");
+
+        let url =
+            format!("https://pro-api.coinmarketcap.com/v2/cryptocurrency/info?symbol={symbols}");
+
+        debug!(url = url, "Fetching assets data from CMC");
+
+        let Some(res) = self.fetch_cmc_api::<CMCInfoResult>(&url).await? else {
+            return Ok(vec![]);
+        };
+
+        if let Some(msg) = res.status.error_message {
+            error!("Error occurred: {:?}", msg);
+            return Err(DcaError::Generic(format!("{:?}", msg)));
+        }
+
+        let assets = res
+            .data
+            .into_values()
+            .map(|mut d| d.swap_remove(0))
+            .map(|d| {
+                Asset::Crypto(Crypto {
+                    id: d.symbol.to_lowercase(),
+                    symbol: d.name,
+                })
+            })
+            .collect_vec();
+
+        Ok(assets)
+    }
+
+    async fn fetch_kraken_api<T: DeserializeOwned + Debug>(&self, url: &str) -> Result<Option<T>> {
+        self.kraken_circuit_breaker
+            .call(self.fetch_kraken_api_inner::<T>(url))
+            .await
+            .map_err(|e| DcaError::from_failsafe(e, "KrakenProvider"))
+    }
+
+    async fn fetch_kraken_api_inner<T: DeserializeOwned + Debug>(
+        &self,
+        url: &str,
+    ) -> Result<Option<T>> {
+        let res = self.http.get(url).send().await?;
+
+        if res.status().is_success() {
+            Ok(Some(res.json::<T>().await?))
+        } else if let StatusCode::NOT_FOUND = res.status() {
+            Ok(None)
+        } else {
+            Err(res.error_for_status().unwrap_err().into())
+        }
+    }
+
+    async fn fetch_cmc_api<T: DeserializeOwned + Debug>(&self, url: &str) -> Result<Option<T>> {
+        self.cmc_circuit_breaker
+            .call(self.fetch_cmc_api_inner::<T>(url))
+            .await
+            .map_err(|e| DcaError::from_failsafe(e, "CoinMarketCapProvider"))
+    }
+
+    async fn fetch_cmc_api_inner<T: DeserializeOwned + Debug>(
+        &self,
+        url: &str,
+    ) -> Result<Option<T>> {
+        let res = self
+            .http
+            .get(url)
+            .header("X-CMC_PRO_API_KEY", self.cmc_api_key.as_ref().unwrap())
+            .send()
+            .await?;
+
+        if res.status().is_success() {
+            Ok(Some(res.json::<T>().await?))
+        } else if let StatusCode::NOT_FOUND = res.status() {
+            Ok(None)
+        } else {
+            Err(res.error_for_status().unwrap_err().into())
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct Pair {
+    wsname: String,
+    status: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct AssetPairsResponse {
+    error: Vec<String>,
+    result: HashMap<String, Pair>,
+}
+
+async fn resolve_assets_data_kraken_only(
+    market_symbols: &[String],
+    repo: &MarketDataRepository,
+) -> (Vec<Market>, Vec<Asset>) {
+    let pairs = get_new_markets(market_symbols, repo).await;
+    let ccys = get_unique_ccys(&pairs);
+    let assets_map = fetch_assets(&ccys, repo).await;
+
+    let markets = pairs
+        .into_iter()
+        .filter_map(|(base_id, quote_id)| {
+            let Some(base) = assets_map.get(&base_id) else {
+                error!("Skipping market {base_id}{quote_id}: base asset data not found");
+                return None;
+            };
+
+            let Some(quote) = assets_map.get(&quote_id) else {
+                error!("Skipping market {base_id}{quote_id}: quote asset data not found");
+                return None;
+            };
+
+            Some(Market::new(
+                format!("{base_id}{quote_id}"),
+                base.clone(),
+                quote.clone(),
+            ))
+        })
+        .collect::<Vec<_>>();
+
+    let assets = assets_map.into_values().collect();
+
+    (markets, assets)
+}
+
+fn normalize_symbol(wsname: &str) -> String {
+    wsname
+        .replace("XBT/", "BTC/")
+        .replace("/XBT", "/BTC")
+        .to_lowercase()
+}
+
+type MarketPair = (String, String);
+
+fn split_base_quote(market_symbols: &[String]) -> Vec<MarketPair> {
+    market_symbols
+        .iter()
+        .filter_map(|id| match id.split_once('/') {
+            Some((base_id, quote_id)) => Some((base_id.to_string(), quote_id.to_string())),
+            None => {
+                error!("Malformed market id: {id}");
+                None
+            }
+        })
+        .collect()
+}
+
+async fn get_new_markets(
+    market_symbols: &[String],
+    repo: &MarketDataRepository,
+) -> Vec<MarketPair> {
+    futures::stream::iter(split_base_quote(market_symbols))
+        .filter(|(base, quote)| is_new_market(format!("{base}{quote}"), repo))
+        .collect::<_>()
+        .await
+}
+
+fn get_unique_ccys(market_pairs: &[MarketPair]) -> HashSet<AssetId> {
+    market_pairs
+        .iter()
+        .flat_map(|(base, quote)| [base.clone(), quote.clone()])
+        .collect::<_>()
+}
+
+async fn fetch_assets(
+    ccys: &HashSet<AssetId>,
+    repo: &MarketDataRepository,
+) -> BTreeMap<String, Asset> {
+    let mut assets = BTreeMap::new();
+    for ccy in ccys {
+        match repo.find_asset(ccy).await {
+            Ok(asset) => {
+                if let Some(a) = asset {
+                    assets.insert(a.id().clone(), a);
+                } else {
+                    let a = match fiat::is_fiat(ccy) {
+                        true => Asset::Fiat(Fiat {
+                            id: ccy.to_string(),
+                            symbol: fiat::get_name(ccy).to_string(),
+                        }),
+                        false => Asset::Crypto(Crypto::new_with_id(ccy.clone())),
+                    };
+                    assets.insert(ccy.clone(), a);
+                }
+            }
+            Err(e) => {
+                error!("Failed to find asset '{ccy}': {e}");
+                continue;
+            }
+        }
+    }
+    assets
+}
+
+mod fiat {
+    use lazy_static::lazy_static;
+    use std::collections::HashMap;
+
+    lazy_static! {
+        static ref FIAT_IDS: HashMap<&'static str, &'static str> = HashMap::from([
+            ("aed", "United Arab Emirates Dirham"),
+            ("aud", "Australian Dollar"),
+            ("cad", "Canadian Dollar"),
+            ("chf", "Swiss franc"),
+            ("eur", "Euro"),
+            ("gbp", "British Pound"),
+            ("jpy", "Japanese Yen"),
+            ("usd", "United States Dollar")
+        ]);
+    }
+
+    pub fn is_fiat(id: &str) -> bool {
+        FIAT_IDS.contains_key(id)
+    }
+
+    pub fn get_name(id: &str) -> &'static str {
+        FIAT_IDS.get(id).unwrap()
+    }
+}
+
+async fn is_new_market(id: MarketId, repo: &MarketDataRepository) -> bool {
+    let mkt = repo.find_market(&id).await.unwrap_or_else(|e| {
+        error!("Error occurred in searching market '{}': {}", id, e);
+        None
+    });
+
+    mkt.is_none()
+}
+
+fn get_kraken_api_periods(freq: OHLCFrequency) -> &'static str {
+    match freq {
+        OHLCFrequency::Minutes5 => "5",
+        OHLCFrequency::Daily => "1440",
+    }
+}
+
+#[derive(Debug, Clone)]
+struct KAssetsDataCurrency {
+    symbol: String,
+    name: String,
+    is_fiat: bool,
+}
+
+#[allow(clippy::from_over_into)]
+impl Into<Asset> for KAssetsDataCurrency {
+    fn into(self) -> Asset {
+        if self.is_fiat {
+            Asset::Fiat(Fiat {
+                id: self.symbol,
+                symbol: self.name,
+            })
+        } else {
+            Asset::Crypto(Crypto {
+                id: self.symbol,
+                symbol: self.name,
+            })
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+struct KAssetData {
+    _base: KAssetsDataCurrency,
+    _quote: KAssetsDataCurrency,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct OHLCResult {
+    error: Vec<String>,
+    result: HashMap<String, Payload>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+enum Payload {
+    Last(i64),
+    CandleSticks(CandleSticks),
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(transparent)]
+struct CandleSticks(Vec<[IntOrStr; 8]>);
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+enum IntOrStr {
+    Int(i64),
+    Str(String),
+}
+
+impl IntOrStr {
+    pub fn as_i64(&self) -> Option<i64> {
+        if let IntOrStr::Int(i) = self {
+            Some(*i)
+        } else {
+            None
+        }
+    }
+
+    pub fn as_str(&self) -> Option<&str> {
+        if let IntOrStr::Str(s) = self {
+            Some(s)
+        } else {
+            None
+        }
+    }
+
+    pub fn as_f64(&self) -> Option<f64> {
+        self.as_str().and_then(|s| s.parse::<f64>().ok())
+    }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CMCInfoResult {
+    status: CMCInfoStatus,
+    data: HashMap<String, Vec<CMCInfoData>>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[allow(dead_code)]
+struct CMCInfoStatus {
+    error_code: u32,
+    error_message: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[allow(dead_code)]
+struct CMCInfoData {
+    id: u64,
+    symbol: String,
+    name: String,
+}
diff --git a/dcapal-backend/src/adapter/mod.rs b/dcapal-backend/src/adapter/mod.rs
index 78a8ccbe..eade8ee6 100644
--- a/dcapal-backend/src/adapter/mod.rs
+++ b/dcapal-backend/src/adapter/mod.rs
@@ -1,7 +1,20 @@
 mod cw;
 mod ipapi;
+mod kraken;
 mod yahoo;
 
 pub use cw::*;
 pub use ipapi::*;
+pub use kraken::*;
 pub use yahoo::*;
+
+use failsafe::{
+    backoff::EqualJittered,
+    failure_policy::{ConsecutiveFailures, OrElse, SuccessRateOverTimeWindow},
+    StateMachine,
+};
+
+type DefaultCircuitBreaker = StateMachine<
+    OrElse<SuccessRateOverTimeWindow<EqualJittered>, ConsecutiveFailures<EqualJittered>>,
+    (),
+>;
diff --git a/dcapal-backend/src/config.rs b/dcapal-backend/src/config.rs
index 7e1a08a5..cdcd1ffb 100644
--- a/dcapal-backend/src/config.rs
+++ b/dcapal-backend/src/config.rs
@@ -6,6 +6,7 @@ use crate::error::Result;
 #[serde(rename_all = "lowercase")]
 pub enum PriceProvider {
     CryptoWatch,
+    Kraken,
     Yahoo,
 }
 
@@ -33,6 +34,7 @@ pub struct Providers {
     pub price_provider: PriceProvider,
     pub cw_api_key: String,
     pub ip_api_key: String,
+    pub cmc_api_key: Option<String>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
diff --git a/dcapal-backend/src/domain/entity.rs b/dcapal-backend/src/domain/entity.rs
index 0403eec3..cc1dc342 100644
--- a/dcapal-backend/src/domain/entity.rs
+++ b/dcapal-backend/src/domain/entity.rs
@@ -14,12 +14,25 @@ pub struct Crypto {
     pub symbol: String,
 }
 
+impl Crypto {
+    pub fn new_with_id(id: AssetId) -> Self {
+        let symbol = id.to_uppercase();
+        Self { id, symbol }
+    }
+}
+
 #[derive(Debug, Clone, Deserialize, Serialize)]
 pub struct Fiat {
     pub id: AssetId,
     pub symbol: String,
 }
 
+impl Fiat {
+    pub fn new(id: AssetId, symbol: String) -> Self {
+        Self { id, symbol }
+    }
+}
+
 #[derive(Debug, Clone, Deserialize, Serialize)]
 #[serde(tag = "type")]
 pub enum Asset {
diff --git a/dcapal-backend/src/domain/mod.rs b/dcapal-backend/src/domain/mod.rs
index 02d4b003..144fa73e 100644
--- a/dcapal-backend/src/domain/mod.rs
+++ b/dcapal-backend/src/domain/mod.rs
@@ -172,6 +172,7 @@ impl MarketDataService {
         let now = Utc::now();
         let price = match self.config.app.providers.price_provider {
             PriceProvider::CryptoWatch => self.providers.cw.fetch_market_price(&mkt, now).await?,
+            PriceProvider::Kraken => self.providers.kraken.fetch_market_price(&mkt, now).await?,
             PriceProvider::Yahoo => self.providers.yahoo.fetch_market_price(&mkt, now).await?,
         };
         if let Some(px) = price {
@@ -411,19 +412,63 @@ impl MarketDataService {
         Ok(())
     }
 
+    pub async fn update_kraken_data(&self) -> Result<()> {
+        // Collect assets and markets from Kraken
+        let (assets, markets) = self.providers.kraken.fetch_assets(&self.repo).await?;
+
+        // Store assets in repository
+        for a in assets {
+            info!("Storing asset '{}'", a.id());
+            self.repo.store_asset(&a).await.unwrap_or_else(|e| {
+                error!(
+                    "Failed to store asset '{}': {} ({})",
+                    a.id(),
+                    e,
+                    serde_json::to_string(&a).unwrap()
+                );
+            })
+        }
+
+        // Store markets in repository
+        for m in &markets {
+            info!("Storing market '{}'", m.id);
+            self.repo.store_market(m).await.unwrap_or_else(|e| {
+                error!(
+                    "Failed to store market '{}': {} ({})",
+                    m.id,
+                    e,
+                    serde_json::to_string(m).unwrap()
+                );
+            })
+        }
+
+        // Invalidate caches
+        self.invalidate_asset_cache();
+        self.mkt_loaders.clear();
+        self.pricers.clear();
+
+        Ok(())
+    }
+
     pub async fn update_market_prices(&self) -> Result<()> {
         let markets = self.repo.load_markets().await?;
 
+        let mut failed_id = None;
         for m in &markets {
             // Trigger market refresh
             if let Err(e) = self.get_market(m.id.clone()).await {
                 warn!("{:?}", e);
+                failed_id = Some(m.id.clone());
             }
             // Please the rate limiter
             tokio::time::sleep(Duration::from_millis(50)).await;
         }
 
-        Ok(())
+        if let Some(id) = failed_id {
+            Err(DcaError::PriceNotAvailableId(id))
+        } else {
+            Ok(())
+        }
     }
 }
 
diff --git a/dcapal-backend/src/error.rs b/dcapal-backend/src/error.rs
index 5f217aeb..97595d14 100644
--- a/dcapal-backend/src/error.rs
+++ b/dcapal-backend/src/error.rs
@@ -23,6 +23,8 @@ pub enum DcaError {
     MarketNotFound(MarketId),
     #[error("Failed to store in Repository: {0}")]
     RepositoryStoreFailure(String),
+    #[error("External service died: {0}")]
+    ExternalServiceDied(String),
     #[error("{0}")]
     StartupFailure(String, #[source] anyhow::Error),
     #[error("Failed to parse config")]
@@ -59,6 +61,13 @@ impl DcaError {
             current: (self as &dyn std::error::Error).source(),
         }
     }
+
+    pub fn from_failsafe(e: failsafe::Error<DcaError>, service: &str) -> Self {
+        match e {
+            failsafe::Error::Inner(e) => e,
+            failsafe::Error::Rejected => DcaError::ExternalServiceDied(service.to_string()),
+        }
+    }
 }
 
 pub type Result<T> = std::result::Result<T, DcaError>;
diff --git a/dcapal-backend/src/lib.rs b/dcapal-backend/src/lib.rs
index a4e9a724..f3980aa4 100644
--- a/dcapal-backend/src/lib.rs
+++ b/dcapal-backend/src/lib.rs
@@ -12,7 +12,7 @@ use crate::{
     error::{DcaError, Result},
 };
 
-use adapter::{CryptoWatchProvider, IpApi, YahooProvider};
+use adapter::{CryptoWatchProvider, IpApi, KrakenProvider, YahooProvider};
 use axum::{
     extract::connect_info::IntoMakeServiceWithConnectInfo, middleware, routing::get, Router,
 };
@@ -65,6 +65,7 @@ struct Repository {
 #[derive(Clone)]
 pub struct Provider {
     pub cw: Arc<CryptoWatchProvider>,
+    pub kraken: Arc<KrakenProvider>,
     pub yahoo: Arc<YahooProvider>,
     pub ipapi: Arc<IpApi>,
 }
@@ -84,7 +85,7 @@ impl DcaServer {
         let http = reqwest::Client::builder()
             .gzip(true)
             .connect_timeout(Duration::from_secs(10))
-            .timeout(Duration::from_secs(300))
+            .timeout(Duration::from_secs(10))
             .build()?;
 
         let redis = build_redis_pool(&config.server.redis)?;
@@ -100,6 +101,7 @@ impl DcaServer {
                 http.clone(),
                 &config.app.providers,
             )),
+            kraken: Arc::new(KrakenProvider::new(http.clone(), &config.app.providers)),
             yahoo: Arc::new(YahooProvider::new(http.clone())),
             ipapi: Arc::new(IpApi::new(http.clone(), &config.app.providers)),
         });
diff --git a/dcapal-backend/src/maintenance.rs b/dcapal-backend/src/maintenance.rs
index 09c23f28..fc49d6d4 100644
--- a/dcapal-backend/src/maintenance.rs
+++ b/dcapal-backend/src/maintenance.rs
@@ -39,13 +39,17 @@ pub async fn run(ctx: AppContext, mut stop_rx: watch::Receiver<bool>) {
             continue;
         }
 
-        info!("Updating CW market data");
-        if let Err(e) = mkt_data.update_cw_data().await {
-            error!("Failed to update CW Assets and Markets data: {:?}", e);
+        let mut _is_err = false;
+
+        info!("Updating Kraken market data");
+        if let Err(e) = mkt_data.update_kraken_data().await {
+            error!("Failed to update Kraken Assets and Markets data: {:?}", e);
+            _is_err = true;
         }
 
         if let Err(e) = mkt_data.update_market_prices().await {
             error!("Failed to update Market prices: {:?}", e);
+            _is_err = true;
         }
 
         let now = Utc::now();
diff --git a/dcapal-backend/src/repository/market_data/redis_asset.rs b/dcapal-backend/src/repository/market_data/redis_asset.rs
index e542ebbf..540bd810 100644
--- a/dcapal-backend/src/repository/market_data/redis_asset.rs
+++ b/dcapal-backend/src/repository/market_data/redis_asset.rs
@@ -29,28 +29,21 @@ pub trait RedisAsset {
 impl RedisAsset for Asset {
     async fn store(&self, conn: &mut impl redis::AsyncCommands) -> Result<bool> {
         let json = serde_json::to_string(self).unwrap();
-        let (n_records, n_index): (i32, i32) = redis::pipe()
+        redis::pipe()
             .atomic()
             .hset(ASSET_KEY, self.id(), &json)
             .zadd(self.kind().as_index(), self.id(), 0)
             .query_async(conn)
             .await?;
 
-        if n_records > 0 && n_index > 0 {
-            debug!(
-                "Successfully stored '{} {}': {}",
-                ASSET_KEY,
-                self.id(),
-                json
-            );
-            Ok(true)
-        } else {
-            debug!(
-                "Not stored '{ASSET_KEY} {}': n_records={n_records} n_index={n_index}",
-                self.id()
-            );
-            Ok(false)
-        }
+        debug!(
+            "Successfully stored '{} {}': {}",
+            ASSET_KEY,
+            self.id(),
+            json
+        );
+
+        Ok(true)
     }
 
     async fn find_by_id(
diff --git a/dcapal-backend/src/repository/market_data/redis_market.rs b/dcapal-backend/src/repository/market_data/redis_market.rs
index 5d2cc140..a274aa0f 100644
--- a/dcapal-backend/src/repository/market_data/redis_market.rs
+++ b/dcapal-backend/src/repository/market_data/redis_market.rs
@@ -39,18 +39,10 @@ impl RedisMarket for Market {
     async fn store(&self, conn: &mut impl redis::AsyncCommands) -> Result<bool> {
         let dto = MarketDto::from(self.clone());
         let json = serde_json::to_string(&dto).unwrap();
-        let n_records: i32 = conn.hset(MARKET_KEY, &self.id, &json).await?;
-
-        if n_records > 0 {
-            debug!("Successfully stored '{} {}': {}", MARKET_KEY, self.id, json);
-            Ok(true)
-        } else {
-            debug!(
-                "Not stored '{MARKET_KEY} {}': n_records={n_records}",
-                self.id
-            );
-            Ok(false)
-        }
+        conn.hset(MARKET_KEY, &self.id, &json).await?;
+
+        debug!("Successfully stored '{} {}': {}", MARKET_KEY, self.id, json);
+        Ok(true)
     }
 
     async fn find_by_id(
diff --git a/dcapal-frontend/public/locales/it/translation.json b/dcapal-frontend/public/locales/it/translation.json
index afe21d7a..d331197c 100644
--- a/dcapal-frontend/public/locales/it/translation.json
+++ b/dcapal-frontend/public/locales/it/translation.json
@@ -38,7 +38,7 @@
       "shouldHave": "Avresti dovuto",
       "butWouldHavePaid": "ma avresti pagato",
       "worthOfFees": "in commissioni",
-      "impact": "impatto",
+      "impact": "di impatto",
       "budgetAllocated": "Un momento. Stiamo allocando il tuo budget",
       "allocationReady": "Fatto! La tua allocazione è pronta",
       "backToPortfolio": "Torna al Portafoglio",
diff --git a/dcapal-frontend/src/index.js b/dcapal-frontend/src/index.js
index fca2496a..b6187d62 100644
--- a/dcapal-frontend/src/index.js
+++ b/dcapal-frontend/src/index.js
@@ -5,10 +5,7 @@ import { Provider } from "react-redux";
 import { PersistGate } from "redux-persist/integration/react";
 
 import "./style.css";
-import { App } from "./app";
 import { store, persistor } from "./app/store";
-import { Root } from "./routes/root";
-import ErrorPage from "./routes/errorPage";
 import { Router } from "./routes/router";
 import { BrowserRouter } from "react-router-dom";
 
@@ -18,18 +15,6 @@ import LanguageDetector from "i18next-browser-languagedetector";
 import translationEN from "../public/locales/en/translation.json";
 import translationIT from "../public/locales/it/translation.json";
 
-const router = createBrowserRouter([
-  {
-    path: "/",
-    element: <Root />,
-    errorElement: <ErrorPage />,
-  },
-  {
-    path: "allocate",
-    element: <App />,
-  },
-]);
-
 i18n
   .use(LanguageDetector)
   .use(initReactI18next)
diff --git a/dcapal-optimizer-wasm/Cargo.toml b/dcapal-optimizer-wasm/Cargo.toml
index c434dc6a..026e624d 100644
--- a/dcapal-optimizer-wasm/Cargo.toml
+++ b/dcapal-optimizer-wasm/Cargo.toml
@@ -2,7 +2,8 @@
 name = "dcapal-optimizer-wasm"
 version.workspace = true
 authors.workspace = true
-edition = "2021"
+edition.workspace = true
+rust-version.workspace = true
 
 [lib]
 crate-type = ["cdylib", "rlib"]


Test patch:
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 57578a57..0663e7ce 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -16,7 +16,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: dtolnay/rust-toolchain@stable
         with:
-          toolchain: 1.65.0
+          toolchain: stable
           components: clippy, rustfmt
       - uses: Swatinem/rust-cache@v2