Repository: dcapal/dcapal (language: rust)

Issue:
Optimization constraint over transaction costs
### Summary

Some brokers apply non-negligible transaction fees that can make investing small amounts quite costly. As suggested in [this Reddit comment][1], investors may want to increase their positions only if transaction fees represent less than some percentage limit of the invested amount.

DcaPal should provide a transaction fees aware optimizer that refuses to invest on an asset if fees are greater than a user-defined percentage.

### Design proposal

Brokers implement the most creative fee structures, varying based on number of trades made per year, size of the investment or asset class. Despite that, they all come down to:

- Absolute fee (e.g. €5 per trade)
- Percentage fee with minimum and maximum (e.g. 0.19% min €1.5 max 18€)

DcaPal should allow a user to define their **fee structure** and **maximum fee rate** ($\hat{r_i}$) they are willing to spend. After computing the amount $a_i$ to allocate on asset $i$, the `OPT` should compute the impact of fee $f_i$ on the investment. 

$$ \frac{f_i}{a_i} > \hat{r_i} \Rightarrow \text{Revert the allocation} $$

_Note:_ Budget reduction due to fees is out of scope, i.e. `OPT` should allocate the budget as if no fees are due.

#### UI changes

- Add general fee structure component in `PortfolioStep` with the following options
  - No fees
  - Absolute fees with `InputNumber` for the amount
  - Percentage fees with percentage, min and max amounts
- Add fee structure component under `Advanced` section of `AssetCard` to set asset-specific fee structure
- Add what-if fee impact on unallocated assets because of too high fees:
  - Amount would have invested
  - Fee would have paid
  - Fee impact

##### Mockups

<img width="2118" alt="mockup" src="https://github.com/leonardoarcari/dcapal/assets/811969/24c19d1f-e2ff-4217-ae8d-ab54a7db0616">

[1]: https://www.reddit.com/r/ItaliaPersonalFinance/comments/12l1r4t/comment/jg7immf/


Discussion / hints:

Fix patch:
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..94f480de
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,1 @@
+* text=auto eol=lf
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 96879590..50aceed5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -59,11 +59,10 @@ jobs:
           context: ./dcapal-backend
           file: ./dcapal-backend/docker/Dockerfile
           push: true
-          tags: ${{ env.DOCKER_REPO }}:latest,${{ env.DOCKER_REPO }}:${{ env.VERSION }} 
+          tags: ${{ env.DOCKER_REPO }}:latest,${{ env.DOCKER_REPO }}:${{ env.VERSION }}
           cache-from: type=local,src=/tmp/.buildx-cache
           cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
-      -
-        # Temp fix
+      - # Temp fix
         # https://github.com/docker/build-push-action/issues/252
         # https://github.com/moby/buildkit/issues/1896
         name: Move cache
@@ -74,64 +73,64 @@ jobs:
   optimizer-build:
     runs-on: ubuntu-22.04
     steps:
-    - uses: actions/checkout@v3
-    - uses: dtolnay/rust-toolchain@stable
-      with:
-        toolchain: 1.65.0
-    - uses: Swatinem/rust-cache@v2
-    
-    - name: Install wasm-pack
-      run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
-    
-    - name: Toolchain info
-      run: |
-        cargo --version --verbose
-        rustc --version    
-        
-    - name: Build
-      run: |
-        cd dcapal-optimizer-wasm
-        wasm-pack build --release
-        
-    - name: Archive dcapal-optimizer-wasm pkg
-      uses: actions/upload-artifact@v3
-      with:
-        name: dcapal-optimizer-wasm-pkg
-        path: dcapal-optimizer-wasm/pkg
+      - uses: actions/checkout@v3
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          toolchain: stable
+      - uses: Swatinem/rust-cache@v2
+
+      - name: Install wasm-pack
+        run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
+
+      - name: Toolchain info
+        run: |
+          cargo --version --verbose
+          rustc --version
+
+      - name: Build
+        run: |
+          cd dcapal-optimizer-wasm
+          wasm-pack build --release
+
+      - name: Archive dcapal-optimizer-wasm pkg
+        uses: actions/upload-artifact@v3
+        with:
+          name: dcapal-optimizer-wasm-pkg
+          path: dcapal-optimizer-wasm/pkg
 
   frontend-build:
     runs-on: ubuntu-22.04
     needs: optimizer-build
     steps:
-    - uses: actions/checkout@v3
-    - uses: actions/setup-node@v3
-      with:
-        node-version: 18
-        cache: 'npm'
-        cache-dependency-path: dcapal-frontend/package-lock.json
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 18
+          cache: "npm"
+          cache-dependency-path: dcapal-frontend/package-lock.json
 
-    - name: Download dcapal-optimizer-wasm pkg
-      uses: actions/download-artifact@v3
-      with:
-        name: dcapal-optimizer-wasm-pkg
-        path: dcapal-optimizer-wasm/pkg
-    
-    - name: Install dependencies
-      run: |
-        cd dcapal-frontend
-        npm ci
+      - name: Download dcapal-optimizer-wasm pkg
+        uses: actions/download-artifact@v3
+        with:
+          name: dcapal-optimizer-wasm-pkg
+          path: dcapal-optimizer-wasm/pkg
+
+      - name: Install dependencies
+        run: |
+          cd dcapal-frontend
+          npm ci
+
+      - name: Build
+        run: |
+          cd dcapal-frontend
+          npm run build
 
-    - name: Build
-      run: |
-        cd dcapal-frontend
-        npm run build
+      - name: Archive dcapal-frontend
+        uses: actions/upload-artifact@v3
+        with:
+          name: dcapal-frontend-dist
+          path: dcapal-frontend/dist
 
-    - name: Archive dcapal-frontend
-      uses: actions/upload-artifact@v3
-      with:
-        name: dcapal-frontend-dist
-        path: dcapal-frontend/dist
-  
   deploy:
     runs-on: ubuntu-22.04
     needs: [build-docker-image, frontend-build]
@@ -142,7 +141,7 @@ jobs:
         with:
           name: dcapal-frontend-dist
           path: dcapal-frontend/dist
-      
+
       - name: Deploy dcapal-frontend
         uses: appleboy/scp-action@master
         with:
@@ -153,7 +152,7 @@ jobs:
           source: dcapal-frontend/dist
           target: ${{ secrets.WEB_DIR }}
           strip_components: 2
-      
+
       - name: Configure docker-compose.yml
         env:
           REDIS_PORT: ${{ secrets.REDIS_PORT }}
@@ -260,7 +259,7 @@ jobs:
               user: ${REDIS_USER}
               password: ${REDIS_PASSWORD}
           EOF
-      
+
       - name: Deploy dcapal-backend config
         uses: appleboy/scp-action@master
         with:
diff --git a/Cargo.lock b/Cargo.lock
index 81672b13..72f9cf44 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -171,6 +171,18 @@ version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
 [[package]]
 name = "block-buffer"
 version = "0.10.3"
@@ -233,9 +245,9 @@ checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba"
 
 [[package]]
 name = "bytecheck"
-version = "0.6.10"
+version = "0.6.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13fe11640a23eb24562225322cd3e452b93a3d4091d62fab69c70542fcd17d1f"
+checksum = "8b6372023ac861f6e6dc89c8344a8f398fb42aaba2b5dbc649ca0c0e9dbcb627"
 dependencies = [
  "bytecheck_derive",
  "ptr_meta",
@@ -244,21 +256,15 @@ dependencies = [
 
 [[package]]
 name = "bytecheck_derive"
-version = "0.6.10"
+version = "0.6.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e31225543cb46f81a7e224762764f4a6a0f097b1db0b175f69e8065efaa42de5"
+checksum = "a7ec4c6f261935ad534c0c22dbef2201b45918860eb1c574b972bd213a76af61"
 dependencies = [
  "proc-macro2",
  "quote",
  "syn 1.0.103",
 ]
 
-[[package]]
-name = "byteorder"
-version = "1.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
-
 [[package]]
 name = "bytes"
 version = "1.2.1"
@@ -553,6 +559,7 @@ dependencies = [
  "serde-wasm-bindgen",
  "serde_json",
  "test-log",
+ "thiserror",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "wasm-bindgen-test",
@@ -701,6 +708,12 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
 [[package]]
 name = "futures"
 version = "0.3.25"
@@ -1648,6 +1661,12 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
 [[package]]
 name = "rand"
 version = "0.8.5"
@@ -1813,23 +1832,26 @@ checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0"
 
 [[package]]
 name = "rkyv"
-version = "0.7.40"
+version = "0.7.42"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c30f1d45d9aa61cbc8cd1eb87705470892289bb2d01943e7803b873a57404dc3"
+checksum = "0200c8230b013893c0b2d6213d6ec64ed2b9be2e0e016682b7224ff82cff5c58"
 dependencies = [
+ "bitvec",
  "bytecheck",
  "hashbrown 0.12.3",
  "ptr_meta",
  "rend",
  "rkyv_derive",
  "seahash",
+ "tinyvec",
+ "uuid",
 ]
 
 [[package]]
 name = "rkyv_derive"
-version = "0.7.40"
+version = "0.7.42"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff26ed6c7c4dfc2aa9480b86a60e3c7233543a270a680e10758a507c5a4ce476"
+checksum = "b2e06b915b5c230a17d7a736d1e2e63ee753c256a8614ef3f5147b13a4f5541d"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1859,14 +1881,12 @@ dependencies = [
 
 [[package]]
 name = "rust_decimal"
-version = "1.29.0"
+version = "1.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b1b21b8760b0ef8ae5b43d40913ff711a2053cb7ff892a34facff7a6365375a"
+checksum = "a4c4216490d5a413bc6d10fa4742bd7d4955941d062c0ef873141d6b0e7b30fd"
 dependencies = [
  "arrayvec",
  "borsh",
- "bytecheck",
- "byteorder",
  "bytes",
  "num-traits 0.2.15",
  "rand",
@@ -1877,9 +1897,9 @@ dependencies = [
 
 [[package]]
 name = "rust_decimal_macros"
-version = "1.29.0"
+version = "1.32.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f4709403426683ccd5e1ef02b24a2468fe87f8df8a2a792743bb369d7656efa"
+checksum = "86444b802de0b10ac5e563b5ddb43b541b9705de4e01a50e82194d2b183c1835"
 dependencies = [
  "quote",
  "rust_decimal",
@@ -2161,6 +2181,12 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8"
 
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
 [[package]]
 name = "tempfile"
 version = "3.3.0"
@@ -2535,6 +2561,12 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "uuid"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
+
 [[package]]
 name = "valuable"
 version = "0.1.0"
@@ -2912,6 +2944,15 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
 [[package]]
 name = "yaml-rust"
 version = "0.4.5"
diff --git a/dcapal-backend/src/domain/mod.rs b/dcapal-backend/src/domain/mod.rs
index 5dafc910..02d4b003 100644
--- a/dcapal-backend/src/domain/mod.rs
+++ b/dcapal-backend/src/domain/mod.rs
@@ -204,7 +204,9 @@ impl MarketDataService {
             .get_or_try_init(|| async { self.compute_conversion_rate(base, quote).await })
             .await;
 
-        let Err(e) = price else { return price.map(|p| p.into()); };
+        let Err(e) = price else {
+            return price.map(|p| p.into());
+        };
 
         if let DcaError::PriceNotAvailableId(ref id) = e {
             if let Some(p) = pricer.get().await {
diff --git a/dcapal-backend/src/domain/utils.rs b/dcapal-backend/src/domain/utils.rs
index ded3f5f1..f994f6e6 100644
--- a/dcapal-backend/src/domain/utils.rs
+++ b/dcapal-backend/src/domain/utils.rs
@@ -63,7 +63,9 @@ where
 
     pub async fn get(&self) -> Option<ExpiringOnceCellValue<T>> {
         let lock = self.cell.read().await;
-        let Some(v) = lock.get() else { return None; };
+        let Some(v) = lock.get() else {
+            return None;
+        };
 
         if (self.is_expired)(v) {
             Some(ExpiringOnceCellValue {
diff --git a/dcapal-frontend/.prettierrc b/dcapal-frontend/.prettierrc
new file mode 100644
index 00000000..e335dbfb
--- /dev/null
+++ b/dcapal-frontend/.prettierrc
@@ -0,0 +1,21 @@
+{
+    "arrowParens": "always",
+    "bracketSpacing": true,
+    "bracketSameLine": false,
+    "embeddedLanguageFormatting": "auto",
+    "endOfLine": "lf",
+    "htmlWhitespaceSensitivity": "css",
+    "insertPragma": false,
+    "jsxSingleQuote": false,
+    "parser": "babel",
+    "printWidth": 80,
+    "proseWrap": "preserve",
+    "quoteProps": "as-needed",
+    "requirePragma": false,
+    "semi": true,
+    "singleAttributePerLine": false,
+    "singleQuote": false,
+    "tabWidth": 2,
+    "trailingComma": "es5",
+    "useTabs": false
+}
\ No newline at end of file
diff --git a/dcapal-frontend/package-lock.json b/dcapal-frontend/package-lock.json
index 842ff876..fe8658f2 100644
--- a/dcapal-frontend/package-lock.json
+++ b/dcapal-frontend/package-lock.json
@@ -42,6 +42,7 @@
         "postcss": "^8.4.23",
         "postcss-loader": "^7.2.4",
         "postcss-preset-env": "^8.3.2",
+        "prettier": "3.0.2",
         "style-loader": "^3.3.2",
         "svg-url-loader": "^8.0.0",
         "tailwindcss": "^3.1.8",
@@ -7012,6 +7013,21 @@
       "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
       "dev": true
     },
+    "node_modules/prettier": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
+      "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
     "node_modules/pretty-error": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
@@ -13914,6 +13930,12 @@
       "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
       "dev": true
     },
+    "prettier": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz",
+      "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==",
+      "dev": true
+    },
     "pretty-error": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
diff --git a/dcapal-frontend/package.json b/dcapal-frontend/package.json
index 21f21e9e..000bcfd0 100644
--- a/dcapal-frontend/package.json
+++ b/dcapal-frontend/package.json
@@ -5,6 +5,8 @@
   "main": "index.js",
   "scripts": {
     "build": "webpack --config webpack.prod.js --mode production",
+    "check": "prettier --check \"src/**/*.js\"",
+    "format": "prettier --write \"src/**/*.js\"",
     "start": "webpack-dev-server --open --config webpack.dev.js --mode development"
   },
   "repository": {
@@ -52,6 +54,7 @@
     "postcss": "^8.4.23",
     "postcss-loader": "^7.2.4",
     "postcss-preset-env": "^8.3.2",
+    "prettier": "3.0.2",
     "style-loader": "^3.3.2",
     "svg-url-loader": "^8.0.0",
     "tailwindcss": "^3.1.8",
diff --git a/dcapal-frontend/src/app/store.js b/dcapal-frontend/src/app/store.js
index 57503121..11d15290 100644
--- a/dcapal-frontend/src/app/store.js
+++ b/dcapal-frontend/src/app/store.js
@@ -1,5 +1,8 @@
 import { combineReducers, configureStore } from "@reduxjs/toolkit";
-import portfolioReducer from "../components/allocationFlow/portfolioStep/portfolioSlice";
+import portfolioReducer, {
+  FeeType,
+  getDefaultFees,
+} from "../components/allocationFlow/portfolioStep/portfolioSlice";
 import appReducer from "./appSlice";
 import storage from "redux-persist/lib/storage";
 import {
@@ -12,15 +15,14 @@ import {
   PURGE,
   REGISTER,
 } from "redux-persist";
+import createMigrate from "redux-persist/es/createMigrate";
+import { mapValues } from "../utils";
 
-const rootConfig = {
-  key: "root",
-  version: 1,
-  storage,
-  migrate: (state) => {
+const migrations = {
+  0: (state) => {
     const assets = state?.pfolio?.assets;
     if (!assets || Object.keys(assets).length === 0) {
-      return Promise.resolve(state);
+      return state;
     }
 
     const anyAsset = Object.values(assets)[0];
@@ -28,13 +30,30 @@ const rootConfig = {
       console.log(
         "Old 'pfolio' persisted state version, missing 'aclass' property. Purging state"
       );
-      return Promise.resolve({});
+      return {};
     }
 
-    return Promise.resolve(state);
+    return state;
+  },
+  2: (state) => {
+    return {
+      ...state,
+      pfolio: {
+        ...state.pfolio,
+        fees: getDefaultFees(FeeType.ZERO_FEE),
+        assets: mapValues(state.pfolio.assets, (a) => ({ ...a, fees: null })),
+      },
+    };
   },
 };
 
+const rootConfig = {
+  key: "root",
+  version: 2,
+  storage,
+  migrate: createMigrate(migrations, { debug: false }),
+};
+
 const rootReducer = combineReducers({
   app: appReducer,
   pfolio: portfolioReducer,
diff --git a/dcapal-frontend/src/components/allocationFlow/endStep/allocateCard.js b/dcapal-frontend/src/components/allocationFlow/endStep/allocateCard.js
index fb3829d1..8f94d82c 100644
--- a/dcapal-frontend/src/components/allocationFlow/endStep/allocateCard.js
+++ b/dcapal-frontend/src/components/allocationFlow/endStep/allocateCard.js
@@ -4,7 +4,28 @@ import { useSelector } from "react-redux";
 import { MEDIA_SMALL } from "../../../app/config";
 import { UNALLOCATED_CASH } from ".";
 import { roundDecimals } from "../../../utils";
-import { ACLASS } from "../portfolioStep/portfolioSlice";
+import { ACLASS, FeeType } from "../portfolioStep/portfolioSlice";
+
+const feeAmount = (fees, amount) => {
+  if (!fees) return 0;
+
+  switch (fees.feeStructure.type) {
+    case FeeType.ZERO_FEE:
+      return 0;
+    case FeeType.FIXED:
+      return amount > 0 ? fees.feeStructure.feeAmount : 0;
+    case FeeType.VARIABLE:
+      return Math.min(
+        Math.max(
+          (fees.feeStructure.feeRate * amount) / 100,
+          fees.feeStructure.minFee
+        ),
+        fees.feeStructure.maxFee
+      );
+    default:
+      return 0;
+  }
+};
 
 export const AllocateCard = ({
   symbol,
@@ -18,7 +39,8 @@ export const AllocateCard = ({
   weight,
   oldWeight,
   targetWeight,
-  ...props
+  fees,
+  theoAlloc,
 }) => {
   const quoteCcy = useSelector((state) => state.pfolio.quoteCcy);
   const isMobile = !useMediaQuery(MEDIA_SMALL);
@@ -36,6 +58,28 @@ export const AllocateCard = ({
   const diffSign = diffAmount === 0 ? " " : diffAmount > 0 ? "+" : "-";
   const diffAmountTxt = Math.abs(diffAmount).toLocaleString("en-US", priceFmt);
 
+  const actionIcon = qty === oldQty ? "🏦" : qty > oldQty ? "📈" : "📉";
+
+  const amountFees = feeAmount(fees, Math.abs(diffAmount));
+  const feeImpact =
+    diffAmount === 0 ? 0 : (amountFees / Math.abs(diffAmount)) * 100;
+
+  const theo = (() => {
+    if (!theoAlloc || theoAlloc.shares === oldQty) return null;
+
+    const diffQty = Math.abs(theoAlloc.shares - oldQty);
+    const action = theoAlloc.shares > oldQty ? "bought" : "sold";
+    const feeImpact =
+      (theoAlloc.fees / Math.abs(theoAlloc.amount - (oldAmount || 0))) * 100;
+
+    return {
+      diffQty: diffQty,
+      action: action,
+      fees: theoAlloc.fees,
+      feeImpact: feeImpact,
+    };
+  })();
+
   const bgColor =
     diffAmount > 0
       ? "bg-green-800"
@@ -72,10 +116,10 @@ export const AllocateCard = ({
           </div>
         </div>
         <p className="font-light text-center">
+          💰{" "}
           <span className="italic">
             This is your budget left. Save it for the next time you visit DcaPal
-          </span>{" "}
-          💰
+          </span>
         </p>
       </div>
     );
@@ -100,116 +144,169 @@ export const AllocateCard = ({
         </div>
         {qty >= 0 && (
           <div>
-            <p className="px-3 py-2 mt-[5px] rounded-md bg-blue-100/50">
+            <div className="flex gap-1 px-3 py-2 mt-[5px] rounded-md bg-blue-100/50">
+              <span>{actionIcon}</span>
               {qty > oldQty && (
                 <span className="font-light">
-                  📈 Buy {roundDecimals(qty - oldQty, 6)}{" "}
+                  Buy {roundDecimals(qty - oldQty, 6)}{" "}
                   <span className="uppercase">{symbol}</span> @ {price}{" "}
                   <span className="uppercase">{quoteCcy}</span>
                 </span>
               )}
               {qty < oldQty && (
                 <span className="font-light">
-                  📉 Sell {roundDecimals(oldQty - qty, 6)}{" "}
+                  Sell {roundDecimals(oldQty - qty, 6)}{" "}
                   <span className="uppercase">{symbol}</span> @ {price}{" "}
                   <span className="uppercase">{quoteCcy}</span>
                 </span>
               )}
               {qty === oldQty && (
-                <span className="font-light">
-                  🏦{" "}
-                  <span className="italic">
-                    Nothing to trade this time. Just{" "}
-                    {aclass === ACLASS.CRYPTO ? "hodl" : "hold"}.
-                  </span>
+                <span className="font-light italic">
+                  Nothing to trade. Just{" "}
+                  {aclass === ACLASS.CRYPTO ? "hodl" : "hold"}.
                 </span>
               )}
-            </p>
+            </div>
             <p className="px-2 my-3 border border-gray-200/50" />
           </div>
         )}
-        {isMobile && (
-          <div className="flex flex-col justify-between items-start">
-            <div className="w-full flex items-center justify-between">
-              <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                Current amount
-              </div>
-              <p className="uppercase">
-                {oldAmount.toLocaleString("en-US", priceFmt)} {quoteCcy}
-              </p>
-            </div>
-            <div className="w-full flex items-center justify-between">
-              <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                New amount
-              </div>
-              <p className="uppercase">
-                {amount.toLocaleString("en-US", priceFmt)} {quoteCcy}
-              </p>
-            </div>
-            <div className="w-full flex items-center justify-between">
-              <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                Current weight
-              </div>
-              <p>{oldWeight.toLocaleString("en-US", weightFmt)} %</p>
-            </div>
-            <div className="w-full flex items-center justify-between">
-              <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                New weight
+        <div className="flex flex-col gap-1">
+          {isMobile && (
+            <div className="flex flex-col justify-between items-start">
+              <div className="w-full flex items-center justify-between">
+                <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                  Current amount
+                </div>
+                <p className="uppercase">
+                  {oldAmount.toLocaleString("en-US", priceFmt)} {quoteCcy}
+                </p>
               </div>
-              <p>{weight.toLocaleString("en-US", weightFmt)} %</p>
-            </div>
-            <div className="w-full flex items-center justify-between">
-              <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                Target weight
+              <div className="w-full flex items-center justify-between">
+                <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                  New amount
+                </div>
+                <p className="uppercase">
+                  {amount.toLocaleString("en-US", priceFmt)} {quoteCcy}
+                </p>
               </div>
-              <p>{targetWeight.toLocaleString("en-US", weightFmt)} %</p>
-            </div>
-          </div>
-        )}
-        {!isMobile && (
-          <div className="flex justify-between items-start">
-            <div className="flex flex-col">
-              <div className="flex items-center">
+              <div className="w-full flex items-center justify-between">
                 <div className="min-w-[5.5rem] mr-2 font-light text-xs">
                   Current weight
                 </div>
-                <span>{oldWeight.toLocaleString("en-US", weightFmt)} %</span>
+                <p>{oldWeight.toLocaleString("en-US", weightFmt)} %</p>
               </div>
-              <div className="flex items-center">
+              <div className="w-full flex items-center justify-between">
                 <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                  Current amount
+                  New weight
                 </div>
-                <span className="uppercase">
-                  {oldAmount.toLocaleString("en-US", priceFmt)} {quoteCcy}
-                </span>
+                <p>{weight.toLocaleString("en-US", weightFmt)} %</p>
               </div>
-              <div className="flex items-center">
+              <div className="w-full flex items-center justify-between">
                 <div className="min-w-[5.5rem] mr-2 font-light text-xs">
                   Target weight
                 </div>
-                <div>{targetWeight.toLocaleString("en-US", weightFmt)} %</div>
+                <p>{targetWeight.toLocaleString("en-US", weightFmt)} %</p>
               </div>
-            </div>
-            <div className="flex flex-col">
-              <div className="flex items-center mr-2">
+              <div className="w-full flex items-center justify-between">
                 <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                  New weight
+                  Fees
                 </div>
-                <span className="grow text-right">
-                  {weight.toLocaleString("en-US", weightFmt)} %
-                </span>
+                <p className="uppercase">
+                  {amountFees.toLocaleString("en-US", priceFmt)} {quoteCcy}
+                </p>
               </div>
-              <div className="flex items-center mr-2">
+              <div className="w-full flex items-center justify-between">
                 <div className="min-w-[5.5rem] mr-2 font-light text-xs">
-                  New amount
+                  Fee impact
                 </div>
-                <span className="grow uppercase text-right">
-                  {amount.toLocaleString("en-US", priceFmt)} {quoteCcy}
-                </span>
+                <p>{feeImpact.toLocaleString("en-US", weightFmt)} %</p>
               </div>
             </div>
-          </div>
-        )}
+          )}
+          {!isMobile && (
+            <div className="flex justify-between items-start">
+              <div className="flex flex-col">
+                <div className="flex items-center">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    Current weight
+                  </div>
+                  <span>{oldWeight.toLocaleString("en-US", weightFmt)} %</span>
+                </div>
+                <div className="flex items-center">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    Current amount
+                  </div>
+                  <span className="uppercase">
+                    {oldAmount.toLocaleString("en-US", priceFmt)} {quoteCcy}
+                  </span>
+                </div>
+                <div className="flex items-center">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    Target weight
+                  </div>
+                  <div>{targetWeight.toLocaleString("en-US", weightFmt)} %</div>
+                </div>
+                <div className="flex items-center">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    Fees
+                  </div>
+                  <span className="uppercase">
+                    <div>
+                      {amountFees.toLocaleString("en-US", priceFmt)} {quoteCcy}
+                    </div>
+                  </span>
+                </div>
+              </div>
+              <div className="flex flex-col">
+                <div className="flex items-center mr-2">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    New weight
+                  </div>
+                  <span className="grow text-right">
+                    {weight.toLocaleString("en-US", weightFmt)} %
+                  </span>
+                </div>
+                <div className="flex items-center mr-2">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    New amount
+                  </div>
+                  <span className="grow uppercase text-right">
+                    {amount.toLocaleString("en-US", priceFmt)} {quoteCcy}
+                  </span>
+                </div>
+                <div className="flex items-center mr-2">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs text-white/0">
+                    Spacer
+                  </div>
+                  <span className="grow uppercase text-right text-white/0">
+                    Spacer
+                  </span>
+                </div>
+                <div className="flex items-center mr-2">
+                  <div className="min-w-[5.5rem] mr-2 font-light text-xs">
+                    Fee impact
+                  </div>
+                  <span className="grow text-right">
+                    {feeImpact.toLocaleString("en-US", weightFmt)} %
+                  </span>
+                </div>
+              </div>
+            </div>
+          )}
+          {theo && (
+            <div className="flex gap-1 px-3 py-2 mt-[5px] rounded-md bg-gray-200/50">
+              <span>ℹ️</span>
+              <span className="font-light italic">
+                Should have {theo.action} {roundDecimals(theo.diffQty, 6)}{" "}
+                <span className="uppercase">{symbol}</span> @ {price}{" "}
+                <span className="uppercase">{quoteCcy}</span> but would have
+                paid {theo.fees.toLocaleString("en-US", priceFmt)}{" "}
+                <span className="uppercase">{quoteCcy}</span> worth of fees (
+                {theo.feeImpact.toLocaleString("en-US", weightFmt)}% impact)
+              </span>
+            </div>
+          )}
+        </div>
       </div>
     );
   }
diff --git a/dcapal-frontend/src/components/allocationFlow/endStep/index.js b/dcapal-frontend/src/components/allocationFlow/endStep/index.js
index 9bfe3c71..05afe0be 100644
--- a/dcapal-frontend/src/components/allocationFlow/endStep/index.js
+++ b/dcapal-frontend/src/components/allocationFlow/endStep/index.js
@@ -2,18 +2,20 @@ import React, { useEffect, useState } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import { spawn, Thread, Worker } from "threads";
 import { setAllocationFlowStep, Step } from "../../../app/appSlice";
-import { roundAmount, roundDecimals, timeout } from "../../../utils";
+import { replacer, roundAmount, roundDecimals, timeout } from "../../../utils";
 import { Spinner } from "../../spinner/spinner";
 import {
   ACLASS,
+  FeeType,
   clearBudget,
+  feeTypeToString,
   isWholeShares,
 } from "../portfolioStep/portfolioSlice";
 import { AllocateCard } from "./allocateCard";
 
 export const UNALLOCATED_CASH = "Unallocated cash";
 
-const buildCards = (budget, assets, solution, pfolioCcy) => {
+const buildCards = (assets, solution, pfolioCcy, pfolioFees) => {
   const cards = Object.values(assets).map((a) => ({
     symbol: a.symbol,
     name: a.name,
@@ -26,6 +28,8 @@ const buildCards = (budget, assets, solution, pfolioCcy) => {
     weight: 0,
     oldWeight: a.weight,
     targetWeight: a.targetWeight,
+    fees: a.fees ? a.fees : pfolioFees,
+    theoAlloc: null,
   }));
 
   if (!solution?.amounts) return cards;
@@ -41,9 +45,13 @@ const buildCards = (budget, assets, solution, pfolioCcy) => {
       card.weight = (100 * card.amount) / totalAmount;
     }
 
-    if (solution.shares?.has(card.symbol)) {
+    if (solution?.shares?.has(card.symbol)) {
       card.qty = solution.shares.get(card.symbol);
     }
+
+    if (solution?.theo_allocs?.has(card.symbol)) {
+      card.theoAlloc = solution.theo_allocs.get(card.symbol);
+    }
   }
 
   if (solution.budget_left) {
@@ -59,13 +67,49 @@ const buildCards = (budget, assets, solution, pfolioCcy) => {
       weight: 0,
       oldWeight: 0,
       targetWeight: 0,
+      fees: null,
+      theo_alloc: null,
     });
   }
 
   return cards;
 };
 
-const buildProblemInput = (budget, pfolioAmount, assets, useWholeShares) => {
+const buildFeesInput = (fees) => {
+  if (!fees) {
+    return null;
+  }
+
+  let input = {
+    ...fees,
+    feeStructure: {
+      ...fees.feeStructure,
+      type: feeTypeToString(fees.feeStructure.type),
+    },
+  };
+
+  if (input.maxFeeImpact == null) {
+    delete input.maxFeeImpact;
+  } else if (input.maxFeeImpact) {
+    input.maxFeeImpact /= 100;
+  }
+
+  if (input.feeStructure.feeRate == null) {
+    delete input.feeStructure.feeRate;
+  } else if (input.feeStructure.feeRate) {
+    input.feeStructure.feeRate /= 100;
+  }
+
+  return input;
+};
+
+const buildProblemInput = (
+  budget,
+  pfolioAmount,
+  assets,
+  fees,
+  useWholeShares
+) => {
   if (!useWholeShares) {
     const problemBudget = budget + pfolioAmount;
     const as = Object.values(assets).reduce(
@@ -80,7 +124,7 @@ const buildProblemInput = (budget, pfolioAmount, assets, useWholeShares) => {
       {}
     );
 
-    return [problemBudget, as];
+    return [problemBudget, as, buildFeesInput(fees)];
   } else {
     const as = Object.values(assets).reduce(
       (as, a) => ({
@@ -91,12 +135,13 @@ const buildProblemInput = (budget, pfolioAmount, assets, useWholeShares) => {
           price: a.price,
           target_weight: a.targetWeight / 100,
           is_whole_shares: isWholeShares(a.aclass),
+          fees: buildFeesInput(a.fees),
         },
       }),
       {}
     );
 
-    return [budget, as];
+    return [budget, as, buildFeesInput(fees)];
   }
 };
 
@@ -109,8 +154,9 @@ export const EndStep = ({ useTaxEfficient, useWholeShares }) => {
   const pfolioAmount = useSelector((state) => state.pfolio.totalAmount);
   const assets = useSelector((state) => state.pfolio.assets);
   const quoteCcy = useSelector((state) => state.pfolio.quoteCcy);
+  const fees = useSelector((state) => state.pfolio.fees);
 
-  const cards = solution ? buildCards(budget, assets, solution, quoteCcy) : [];
+  const cards = solution ? buildCards(assets, solution, quoteCcy, fees) : [];
 
   useEffect(() => {
     const launchSolver = async () => {
@@ -120,22 +166,36 @@ export const EndStep = ({ useTaxEfficient, useWholeShares }) => {
         })
       );
 
-      const [inputBudget, as] = buildProblemInput(
+      const [inputBudget, as, inputFees] = buildProblemInput(
         budget,
         pfolioAmount,
         assets,
+        fees,
         useWholeShares
       );
 
+      console.debug(
+        `inputBudget=${inputBudget} as=${JSON.stringify(
+          as
+        )} quoteCcy=${quoteCcy} useTaxEfficient=${useTaxEfficient} useWholeShares=${useWholeShares} inputFees=${JSON.stringify(
+          inputFees
+        )}`
+      );
+
       try {
         const sol = await solver.makeAndSolve(
           inputBudget,
           as,
           quoteCcy,
           useTaxEfficient,
-          useWholeShares
+          useWholeShares,
+          inputFees
         );
+
         await Thread.terminate(solver);
+
+        console.debug(`solution=${JSON.stringify(sol, replacer)}`);
+
         return sol;
       } catch (error) {
         console.error("Unexpected exception in dcapal-optimizer:", error);
@@ -190,6 +250,8 @@ export const EndStep = ({ useTaxEfficient, useWholeShares }) => {
                 weight={c.weight}
                 oldWeight={c.oldWeight}
                 targetWeight={c.targetWeight}
+                fees={c.fees}
+                theoAlloc={c.theoAlloc}
               />
             ))}
           </div>
diff --git a/dcapal-frontend/src/components/allocationFlow/importStep.js b/dcapal-frontend/src/components/allocationFlow/importStep.js
index 1fc0a155..81cf729d 100644
--- a/dcapal-frontend/src/components/allocationFlow/importStep.js
+++ b/dcapal-frontend/src/components/allocationFlow/importStep.js
@@ -8,9 +8,14 @@ import { timeout } from "../../utils";
 import { Spinner } from "../spinner/spinner";
 import {
   ACLASS,
+  FeeType,
   addAsset,
   clearPortfolio,
+  getDefaultFees,
   parseAClass,
+  parseFeeType,
+  setFees,
+  setFeesAsset,
   setQty,
   setQuoteCurrency,
   setTargetWeight,
@@ -20,6 +25,24 @@ import { IKImage } from "imagekitio-react";
 import { IMAGEKIT_URL } from "../../app/config";
 import { HEADER_IMPORT_PORTFOLIO_SVG } from "../../app/images";
 
+const parseFees = (fees) => {
+  if (!fees) return null;
+
+  const parsed = {
+    ...fees,
+    feeStructure: {
+      ...fees.feeStructure,
+      type: parseFeeType(fees.feeStructure.type),
+    },
+  };
+
+  if (!parsed.feeStructure.type) {
+    return null;
+  }
+
+  return parsed;
+};
+
 const importPfolio = async (pfolio, validCcys, dispatch) => {
   const stopWithError = (...args) => {
     console.log(args);
@@ -33,11 +56,21 @@ const importPfolio = async (pfolio, validCcys, dispatch) => {
   dispatch(clearPortfolio());
   dispatch(setQuoteCurrency({ quoteCcy: pfolio.quoteCcy }));
 
+  const fees = (() => {
+    if (pfolio.fees != null && typeof pfolio.fees === "object") {
+      return parseFees(pfolio.fees) || getDefaultFees(FeeType.ZERO_FEE);
+    } else {
+      return getDefaultFees(FeeType.ZERO_FEE);
+    }
+  })();
+
   if (!pfolio.assets || !Array.isArray(pfolio.assets)) {
     stopWithError("[ImportStep] Missing 'assets' property");
     return false;
   }
 
+  dispatch(setFees({ fees: fees }));
+
   for (const a of pfolio.assets) {
     const price = await getFetcher(a.provider, validCcys)(
       a.symbol,
@@ -65,6 +98,16 @@ const importPfolio = async (pfolio, validCcys, dispatch) => {
 
     dispatch(setQty({ symbol: a.symbol, qty: a.qty }));
     dispatch(setTargetWeight({ symbol: a.symbol, weight: a.targetWeight }));
+
+    const assetFees = (() => {
+      if (a.fees != null && typeof a.fees === "object") {
+        return parseFees(a.fees);
+      } else {
+        return null;
+      }
+    })();
+
+    dispatch(setFeesAsset({ symbol: a.symbol, fees: assetFees }));
   }
 
   return true;
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/assetCard.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/assetCard.js
index 4cbe7a71..8b7264ad 100644
--- a/dcapal-frontend/src/components/allocationFlow/portfolioStep/assetCard.js
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/assetCard.js
@@ -5,6 +5,9 @@ import { useMediaQuery } from "@react-hook/media-query";
 import { InputNumber, InputNumberType } from "../../core/inputNumber";
 import { isWholeShares, removeAsset } from "./portfolioSlice";
 import { MEDIA_SMALL } from "../../../app/config";
+import { useCollapse } from "react-collapsed";
+import classNames from "classnames";
+import { TransactionFees } from "./transactionFees";
 
 export const AssetCard = ({
   symbol,
@@ -20,6 +23,7 @@ export const AssetCard = ({
   const quoteCcy = useSelector((state) => state.pfolio.quoteCcy);
   const dispatch = useDispatch();
   const isMobile = !useMediaQuery(MEDIA_SMALL);
+  const { getCollapseProps, getToggleProps, isExpanded } = useCollapse();
 
   const priceFmt = {
     minimumFractionDigits: 2,
@@ -160,6 +164,27 @@ export const AssetCard = ({
           </div>
         </div>
       )}
+      <div className="w-full mt-4 flex flex-col gap-3">
+        <div
+          className="flex gap-1 items-center font-light text-xs"
+          {...getToggleProps()}
+        >
+          <span
+            className={classNames("transition-transform", {
+              "rotate-90": isExpanded,
+            })}
+          >
+            {">"}
+          </span>
+          <span>Transaction fees</span>
+        </div>
+        <div
+          className="w-full flex flex-col gap-1 justify-start text-sm"
+          {...getCollapseProps()}
+        >
+          <TransactionFees asset={symbol} />
+        </div>
+      </div>
     </div>
   );
 };
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js
index 1b005731..85a9949d 100644
--- a/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/index.js
@@ -17,9 +17,11 @@ import { setAllocationFlowStep, Step } from "../../../app/appSlice";
 import { IKImage } from "imagekitio-react";
 import { IMAGEKIT_URL, MEDIA_SMALL } from "../../../app/config";
 import { ICON_BAG_SVG, ICON_PIECHART_SVG } from "../../../app/images";
+import { TransactionFees } from "./transactionFees";
 
 export const PortfolioStep = ({ ...props }) => {
   const [searchText, setSearchText] = useState("");
+  const [isShowFees, setShowFees] = useState(false);
   const assetStore = useSelector((state) => state.pfolio.assets);
   const isMobile = !useMediaQuery(MEDIA_SMALL);
   const dispatch = useDispatch();
@@ -60,15 +62,46 @@ export const PortfolioStep = ({ ...props }) => {
     dispatch(setAllocationFlowStep({ step: Step.INVEST }));
   };
 
+  const onClickTransactionFees = () => {
+    isShowFees ? setShowFees(false) : setShowFees(true);
+  };
+
+  const feeBtnClass = classNames(
+    "px-3 pt-1.5 pb-2 z-30 flex justify-center items-center text-2xl font-medium rounded-md shadow-md border border-gray-300",
+    {
+      "bg-white hover:bg-neutral-600 hover:text-white hover:border-gray-600 active:bg-neutral-800":
+        !isShowFees,
+      "bg-neutral-600 text-white": isShowFees,
+    }
+  );
+
   return (
     <div className="w-full h-full flex flex-col pt-2 items-center">
-      <div className="w-full mt-2 mb-6">
+      <div className="w-full my-2">
         <SearchBar
           text={searchText}
           setText={setSearchText}
           addAsset={addAssetToPortfolio}
         />
       </div>
+      {assets && assets.length > 0 && (
+        <div className="relative w-full flex flex-col items-end justify-center mt-2">
+          <button className={feeBtnClass} onClick={onClickTransactionFees}>
+            ⚙️
+          </button>
+          {isShowFees && (
+            <div className="w-full max-w-lg relative -top-4 px-3 pt-2 pb-3 flex flex-col gap-2 bg-white shadow-md ring-1 ring-black/5 rounded-md">
+              <p className="font-light text-2xl">💸 Transaction fees</p>
+              <TransactionFees />
+            </div>
+          )}
+        </div>
+      )}
+      {assets && assets.length > 0 && (
+        <div className="w-full flex items-center mb-3 pl-3 font-light text-2xl">
+          Portfolio assets
+        </div>
+      )}
       <div className="w-full flex flex-col items-center">
         {assets.map((a, idx) => {
           const setAssetQty = (qty) => {
@@ -103,7 +136,7 @@ export const PortfolioStep = ({ ...props }) => {
       </div>
       {Object.keys(assetStore).length === 0 && (
         <span
-          className="font-medium underline cursor-pointer"
+          className="mt-2 font-medium underline cursor-pointer"
           onClick={onClickDiscard}
         >
           Go back
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js
index d19dc2bc..b420b937 100644
--- a/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/portfolioSlice.js
@@ -50,6 +50,65 @@ export const parseAClass = (aclassStr) => {
   return ACLASS.UNDEFINED;
 };
 
+export const FeeType = Object.freeze({
+  ZERO_FEE: 10,
+  FIXED: 20,
+  VARIABLE: 30,
+});
+
+export const feeTypeToString = (type) => {
+  switch (type) {
+    case FeeType.ZERO_FEE:
+      return "zeroFee";
+    case FeeType.FIXED:
+      return "fixed";
+    case FeeType.VARIABLE:
+      return "variable";
+    default:
+      return "undefined";
+  }
+};
+
+export const parseFeeType = (typeStr) => {
+  if (typeStr === "zeroFee") return FeeType.ZERO_FEE;
+  if (typeStr === "fixed") return FeeType.FIXED;
+  if (typeStr === "variable") return FeeType.VARIABLE;
+
+  return null;
+};
+
+export const getDefaultFees = (type) => {
+  if (!type) {
+    return null;
+  }
+
+  if (type == FeeType.ZERO_FEE) {
+    return {
+      feeStructure: {
+        type: FeeType.ZERO_FEE,
+      },
+    };
+  } else if (type === FeeType.FIXED) {
+    return {
+      maxFeeImpact: null,
+      feeStructure: {
+        type: FeeType.FIXED,
+        feeAmount: 0,
+      },
+    };
+  } else {
+    return {
+      maxFeeImpact: null,
+      feeStructure: {
+        type: FeeType.VARIABLE,
+        feeRate: 0,
+        minFee: 0,
+        maxFee: null,
+      },
+    };
+  }
+};
+
 export const portfolioSlice = createSlice({
   name: "portfolio",
   initialState: {
@@ -58,6 +117,7 @@ export const portfolioSlice = createSlice({
     nextIdx: 0,
     totalAmount: 0,
     budget: 0,
+    fees: getDefaultFees(FeeType.ZERO_FEE),
   },
   reducers: {
     addAsset: (state, action) => {
@@ -78,6 +138,7 @@ export const portfolioSlice = createSlice({
           amount: 0,
           weight: 0,
           targetWeight: 0,
+          fees: null,
         },
       };
       state.nextIdx += 1;
@@ -139,11 +200,146 @@ export const portfolioSlice = createSlice({
         state.budget = action.payload.budget;
       }
     },
+    setFees: (state, action) => {
+      if (action.payload.fees) {
+        state.fees = action.payload.fees;
+      }
+    },
+    setFeesAsset: (state, action) => {
+      const { fees, symbol } = action.payload;
+      if (!symbol || !(symbol in state.assets)) {
+        return;
+      }
+
+      state.assets[symbol].fees = fees;
+    },
+    setFeeType: (state, action) => {
+      if (!action.payload.type) {
+        state.fees = getDefaultFees(FeeType.ZERO_FEE);
+        return;
+      }
+
+      if (!state.fees?.feeStructure?.type) {
+        state.fees = getDefaultFees(action.payload.type);
+        return;
+      }
+
+      if (action.payload.type !== state.fees.feeStructure.type) {
+        state.fees = {
+          ...state.fees,
+          feeStructure: getDefaultFees(action.payload.type).feeStructure,
+        };
+      }
+    },
+    setFeeTypeAsset: (state, action) => {
+      const { type, symbol } = action.payload;
+      if (!symbol || !(symbol in state.assets)) {
+        return;
+      }
+
+      if (!type) {
+        state.assets[symbol].fees = null;
+        return;
+      }
+
+      const asset = state.assets[symbol];
+
+      if (!asset.fees?.feeStructure.type) {
+        asset.fees = getDefaultFees(type);
+        return;
+      }
+
+      if (type !== asset.fees.feeStructure.type) {
+        asset.fees = {
+          ...asset.fees,
+          feeStructure: getDefaultFees(type).feeStructure,
+        };
+      }
+    },
+    setMaxFeeImpact: (state, action) => {
+      if (state.fees) {
+        state.fees = {
+          ...state.fees,
+          maxFeeImpact: action.payload.value,
+        };
+      }
+    },
+    setMaxFeeImpactAsset: (state, action) => {
+      const { value, symbol } = action.payload;
+      if (!symbol || !(symbol in state.assets)) {
+        return;
+      }
+
+      const fees = state.assets[symbol].fees;
+      if (fees) {
+        state.assets[symbol].fees = {
+          ...fees,
+          maxFeeImpact: value,
+        };
+      }
+    },
+    setFixedFeeAmount: (state, action) => {
+      if (state.fees && state.fees.feeStructure?.type === FeeType.FIXED) {
+        state.fees = {
+          ...state.fees,
+          feeStructure: {
+            ...state.fees.feeStructure,
+            feeAmount: action.payload.value,
+          },
+        };
+      }
+    },
+    setFixedFeeAmountAsset: (state, action) => {
+      const { value, symbol } = action.payload;
+      if (!symbol || !(symbol in state.assets)) {
+        return;
+      }
+
+      const fees = state.assets[symbol].fees;
+      if (fees && fees.feeStructure.type === FeeType.FIXED) {
+        state.assets[symbol].fees = {
+          ...fees,
+          feeStructure: {
+            ...fees.feeStructure,
+            feeAmount: value,
+          },
+        };
+      }
+    },
+    setVariableFee: (state, action) => {
+      if (state.fees && state.fees.feeStructure?.type === FeeType.VARIABLE) {
+        state.fees = {
+          ...state.fees,
+          feeStructure: {
+            ...state.fees.feeStructure,
+            ...action.payload,
+          },
+        };
+      }
+    },
+    setVariableFeeAsset: (state, action) => {
+      const { symbol, ...rest } = action.payload;
+      if (!symbol || !(symbol in state.assets)) {
+        return;
+      }
+
+      const fees = state.assets[symbol].fees;
+      if (fees && fees.feeStructure.type === FeeType.VARIABLE) {
+        state.assets[symbol].fees = {
+          ...fees,
+          feeStructure: {
+            ...fees.feeStructure,
+            ...rest,
+          },
+        };
+      }
+    },
     clearPortfolio: (state) => {
       state.assets = {};
       state.nextIdx = 0;
       state.totalAmount = 0;
       state.budget = 0;
+      state.fees = getDefaultFees(FeeType.ZERO_FEE);
     },
     clearBudget: (state) => {
       state.budget = 0;
@@ -157,8 +353,18 @@ export const {
   setQty,
   setTargetWeight,
   setQuoteCurrency,
-  clearPortfolio,
   setBudget,
+  setFees,
+  setFeesAsset,
+  setFeeType,
+  setFeeTypeAsset,
+  setMaxFeeImpact,
+  setMaxFeeImpactAsset,
+  setFixedFeeAmount,
+  setFixedFeeAmountAsset,
+  setVariableFee,
+  setVariableFeeAsset,
+  clearPortfolio,
   clearBudget,
 } = portfolioSlice.actions;
 
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/searchBar.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/searchBar.js
index d5ed4f35..63885534 100644
--- a/dcapal-frontend/src/components/allocationFlow/portfolioStep/searchBar.js
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/searchBar.js
@@ -148,17 +148,17 @@ export const SearchBar = (props) => {
         onChange={handleAddAssetInputChange}
       />
       {isLoading && (
-        <div className="w-[calc(100%-2rem)] px-6 py-3 overflow-auto absolute inset-x-4 top-12 bg-white rounded-sm ring-1 ring-slate-500/50 shadow-lg z-10 flex items-center justify-center font-light italic">
+        <div className="w-[calc(100%-2rem)] px-6 py-3 overflow-auto absolute inset-x-4 top-12 bg-white rounded-sm ring-1 ring-slate-500/50 shadow-lg z-40 flex items-center justify-center font-light italic">
           <Spinner width="2.5rem" height="2.5rem" />
         </div>
       )}
       {results && isEmptyResult && (
-        <div className="w-[calc(100%-2rem)] px-6 py-4 overflow-auto absolute inset-x-4 top-12 bg-white rounded-sm ring-1 ring-slate-500/50 shadow-lg z-10 flex items-center justify-center font-light italic">
+        <div className="w-[calc(100%-2rem)] px-6 py-4 overflow-auto absolute inset-x-4 top-12 bg-white rounded-sm ring-1 ring-slate-500/50 shadow-lg z-40 flex items-center justify-center font-light italic">
           No asset found for '{props.text.toUpperCase()}'
         </div>
       )}
       {results && !isEmptyResult && (
-        <ul className="w-[calc(100%-2rem)] max-h-72 min-h-[10rem] overflow-auto absolute inset-x-4 top-12 bg-white rounded-sm ring-1 ring-slate-500/50 shadow-lg z-10">
+        <ul className="w-[calc(100%-2rem)] max-h-72 min-h-[10rem] overflow-auto absolute inset-x-4 top-12 bg-white rounded-sm ring-1 ring-slate-500/50 shadow-lg z-40">
           {results.fiat.length > 0 && <SearchHeader text="cash" />}
           {results.fiat.map((r) => (
             <SearchItemCW
diff --git a/dcapal-frontend/src/components/allocationFlow/portfolioStep/transactionFees.js b/dcapal-frontend/src/components/allocationFlow/portfolioStep/transactionFees.js
new file mode 100644
index 00000000..e5bb84cf
--- /dev/null
+++ b/dcapal-frontend/src/components/allocationFlow/portfolioStep/transactionFees.js
@@ -0,0 +1,326 @@
+import React from "react";
+import { useDispatch, useSelector } from "react-redux";
+import {
+  FeeType,
+  setFeeType,
+  setFeeTypeAsset,
+  setFixedFeeAmount,
+  setFixedFeeAmountAsset,
+  setMaxFeeImpact,
+  setMaxFeeImpactAsset,
+  setVariableFee,
+  setVariableFeeAsset,
+} from "./portfolioSlice";
+import classNames from "classnames";
+import { InputNumber, InputNumberType } from "../../core/inputNumber";
+
+export const TransactionFees = ({ asset }) => {
+  const dispatch = useDispatch();
+  const quoteCcy = useSelector((state) => state.pfolio.quoteCcy);
+
+  const fees = useSelector((state) => {
+    return asset && asset in state.pfolio.assets
+      ? state.pfolio.assets[asset].fees || state.pfolio.fees
+      : state.pfolio.fees;
+  });
+
+  const feeType = useSelector((state) => {
+    return asset
+      ? asset in state.pfolio.assets && state.pfolio.assets[asset].fees
+        ? state.pfolio.assets[asset].fees.feeStructure.type
+        : null
+      : state.pfolio.fees.feeStructure.type;
+  });
+
+  const maxFeeImpact = fees?.maxFeeImpact || null;
+  const fixedFeeAmount = fees?.feeStructure?.feeAmount || null;
+  const variableFee =
+    feeType === FeeType.VARIABLE
+      ? {
+          feeRate: fees?.feeStructure?.feeRate || null,
+          minFee: fees?.feeStructure?.minFee || null,
+          maxFee: fees?.feeStructure?.maxFee || null,
+        }
+      : null;
+
+  const setSelected = (type) => {
+    if (!asset) {
+      dispatch(setFeeType({ type: type }));
+    } else {
+      dispatch(setFeeTypeAsset({ type: type, symbol: asset }));
+    }
+  };
+
+  const onChangeMaxFeeImpact = (value) => {
+    if (!asset) {
+      dispatch(setMaxFeeImpact({ value: value }));
+    } else {
+      dispatch(setMaxFeeImpactAsset({ value: value, symbol: asset }));
+    }
+  };
+
+  const onChangeFeeAmount = (value) => {
+    if (!asset) {
+      dispatch(setFixedFeeAmount({ value: value }));
+    } else {
+      dispatch(setFixedFeeAmountAsset({ value: value, symbol: asset }));
+    }
+  };
+
+  const onChangeVariableFee = (value) => {
+    if (!asset) {
+      dispatch(setVariableFee({ ...value }));
+    } else {
+      dispatch(setVariableFeeAsset({ ...value, symbol: asset }));
+    }
+  };
+
+  return (
+    <div className="w-full flex flex-col gap-2 items-start justify-center">
+      <FeeGroup selected={feeType} setSelected={setSelected} asset={asset} />
+      {feeType === FeeType.ZERO_FEE && <NoFeesForm />}
+      {feeType === FeeType.FIXED && (
+        <FixedFeeForm
+          quoteCcy={quoteCcy}
+          maxFeeImpact={maxFeeImpact}
+          onChangeMaxFeeImpact={onChangeMaxFeeImpact}
+          feeAmount={fixedFeeAmount}
+          onChangeFeeAmount={onChangeFeeAmount}
+        />
+      )}
+      {feeType === FeeType.VARIABLE && (
+        <VariableFeeForm
+          quoteCcy={quoteCcy}
+          maxFeeImpact={maxFeeImpact}
+          onChangeMaxFeeImpact={onChangeMaxFeeImpact}
+          variableFee={variableFee}
+          onChangeVariableFee={onChangeVariableFee}
+        />
+      )}
+    </div>
+  );
+};
+
+const FeeGroup = ({ selected, setSelected, asset }) => (
+  <div className="flex w-full">
+    {asset && (
+      <FeeRadio
+        key="default"
+        type={null}
+        label="Default"
+        selected={selected}
+        setSelected={setSelected}
+      />
+    )}
+    <FeeRadio
+      key="zeroFees"
+      type={FeeType.ZERO_FEE}
+      label="No fees"
+      selected={selected}
+      setSelected={setSelected}
+    />
+    <FeeRadio
+      key="fixed"
+      type={FeeType.FIXED}
+      label="Fixed"
+      selected={selected}
+      setSelected={setSelected}
+    />
+    <FeeRadio
+      key="variable"
+      type={FeeType.VARIABLE}
+      label="Variable"
+      selected={selected}
+      setSelected={setSelected}
+    />
+  </div>
+);
+
+const FeeRadio = ({ type, label, selected, setSelected }) => {
+  const isSelected = selected === type;
+
+  const onClick = () => {
+    setSelected(type);
+  };
+
+  const commonClass =
+    "px-2 py-1 flex grow justify-center items-center border hover:bg-neutral-600 hover:border-neutral-600 hover:text-white active:bg-neutral-800 active:text-white first:rounded-l-md last:rounded-r-md cursor-pointer select-none";
+
+  const className = classNames(commonClass, {
+    "border-gray-300": !isSelected,
+    "bg-white": !isSelected,
+    "border-gray-500": isSelected,
+    "bg-neutral-500": isSelected,
+    "text-white": isSelected,
+  });
+
+  return (
+    <div className={className} onClick={onClick}>
+      {label}
+    </div>
+  );
+};
+
+const NoFeesForm = () => {
+  return (
+    <p className="font-light text-center self-center">
+      💰{" "}
+      <span className="italic">
+        Enjoy your zero-fee trading life, lucky bastard
+      </span>
+    </p>
+  );
+};
+
+const FixedFeeForm = ({
+  quoteCcy,
+  maxFeeImpact,
+  onChangeMaxFeeImpact,
+  feeAmount,
+  onChangeFeeAmount,
+}) => {
+  return (
+    <div className="w-full flex flex-col justify-center gap-1">
+      <MaxFeeImpactInput
+        quoteCcy={quoteCcy}
+        maxFeeImpact={maxFeeImpact}
+        onChangeMaxFeeImpact={onChangeMaxFeeImpact}
+      />
+      <div className="w-full flex items-center">
+        <label className="min-w-[8rem] mr-2 font-light">Fee amount</label>
+        <div className="grow">
+          <InputNumber
+            textAlign="text-right"
+            type={InputNumberType.DECIMAL}
+            value={feeAmount}
+            onChange={onChangeFeeAmount}
+            isValid={true}
+            min={0}
+          />
+        </div>
+        <label className="text-start min-w-[2rem] ml-2 uppercase">
+          {quoteCcy}
+        </label>
+      </div>
+    </div>
+  );
+};
+
+const VariableFeeForm = ({
+  quoteCcy,
+  maxFeeImpact,
+  onChangeMaxFeeImpact,
+  variableFee,
+  onChangeVariableFee,
+}) => {
+  const { feeRate, minFee, maxFee } = variableFee;
+
+  const onChangeFeeRate = (value) => {
+    onChangeVariableFee({
+      feeRate: value,
+    });
+  };
+
+  const onChangeMinFee = (value) => {
+    onChangeVariableFee({
+      minFee: value,
+    });
+  };
+
+  const onChangeMaxFee = (value) => {
+    onChangeVariableFee({
+      maxFee: value,
+    });
+  };
+
+  const isMinFeeValid = (() => {
+    if (minFee && maxFee) {
+      return minFee <= maxFee;
+    }
+
+    return true;
+  })();
+
+  return (
+    <div className="w-full flex flex-col justify-center gap-1">
+      <MaxFeeImpactInput
+        quoteCcy={quoteCcy}
+        maxFeeImpact={maxFeeImpact}
+        onChangeMaxFeeImpact={onChangeMaxFeeImpact}
+      />
+      <div className="w-full flex items-center">
+        <label className="min-w-[8rem] mr-2 font-light">Fee percentage</label>
+        <div className="grow">
+          <InputNumber
+            textAlign="text-right"
+            type={InputNumberType.DECIMAL}
+            value={feeRate}
+            onChange={onChangeFeeRate}
+            isValid={true}
+            min={0}
+            max={100}
+          />
+        </div>
+        <label className="text-start min-w-[2rem] ml-2 uppercase">%</label>
+      </div>
+      <div className="w-full flex items-center">
+        <label className="min-w-[8rem] mr-2 font-light">Min fee</label>
+        <div className="grow">
+          <InputNumber
+            textAlign="text-right"
+            type={InputNumberType.DECIMAL}
+            value={minFee}
+            onChange={onChangeMinFee}
+            isValid={isMinFeeValid}
+            min={0}
+          />
+        </div>
+        <label className="text-start min-w-[2rem] ml-2 uppercase">
+          {quoteCcy}
+        </label>
+      </div>
+      <div className="w-full flex items-center">
+        <label className="min-w-[8rem] mr-2 font-light">Max fee</label>
+        <div className="grow">
+          <InputNumber
+            textAlign="text-right"
+            type={InputNumberType.DECIMAL}
+            value={maxFee}
+            onChange={onChangeMaxFee}
+            isValid={true}
+            min={0}
+          />
+        </div>
+        <label className="text-start min-w-[2rem] ml-2 uppercase">
+          {quoteCcy}
+        </label>
+      </div>
+      {!isMinFeeValid && (
+        <div className="mt-2 font-light text-red-500 text-center">
+          Review your <span className="font-normal">Min fee</span>. Must be less
+          than or equal to max fee.
+        </div>
+      )}
+    </div>
+  );
+};
+
+const MaxFeeImpactInput = ({ maxFeeImpact, onChangeMaxFeeImpact }) => {
+  return (
+    <div className="w-full flex items-center">
+      <label className="min-w-[8rem] mr-2 font-light">Max fee impact</label>
+      <div className="grow">
+        <InputNumber
+          textAlign="text-right"
+          type={InputNumberType.DECIMAL}
+          value={maxFeeImpact}
+          onChange={onChangeMaxFeeImpact}
+          isValid={true}
+          min={0}
+          max={100}
+        />
+      </div>
+      <label className="text-start min-w-[2rem] ml-2 uppercase">%</label>
+    </div>
+  );
+};
diff --git a/dcapal-frontend/src/components/core/footer.js b/dcapal-frontend/src/components/core/footer.js
index a3a9de85..2d3a6091 100644
--- a/dcapal-frontend/src/components/core/footer.js
+++ b/dcapal-frontend/src/components/core/footer.js
@@ -14,7 +14,9 @@ export const Footer = () => {
         <Link to={"/about"}>About</Link>
         <a href="https://github.com/leonardoarcari/dcapal">Github</a>
       </div>
-      <p className="text-xs text-white/70">© { new Date().getFullYear().toString() } Leonardo Arcari</p>
+      <p className="text-xs text-white/70">
+        © {new Date().getFullYear().toString()} Leonardo Arcari
+      </p>
     </footer>
   );
 };
diff --git a/dcapal-frontend/src/components/core/inputNumber.js b/dcapal-frontend/src/components/core/inputNumber.js
index b21623a0..dcace655 100644
--- a/dcapal-frontend/src/components/core/inputNumber.js
+++ b/dcapal-frontend/src/components/core/inputNumber.js
@@ -33,11 +33,6 @@ export const InputNumber = ({
   };
 
   const handleOnBlur = (e) => {
-    if (e.target.value < 0) {
-      e.preventDefault();
-      return;
-    }
-
     let newValue = e.target.value;
     if (type === InputNumberType.INTEGRAL && newValue.startsWith("0")) {
       newValue = newValue.substr(1);
@@ -51,17 +46,14 @@ export const InputNumber = ({
       newValue = parseInt(newValue);
     }
 
-    if (newValue === NaN) {
+    if (newValue === NaN || newValue < min || newValue > max) {
       newValue = null;
     }
 
     onChange(newValue);
   };
 
-  const invalidClass = isValid
-    ? ""
-    : "border-red-300 hover:border-red-500 focus-visible:outline-red-600";
-  const className = `w-full px-2 pt-1 pb-1.5 border focus-visible:outline-1 rounded-md border-gray-300 hover:border-gray-500 focus-visible:outline-gray-600 ${textAlign} ${invalidClass}`;
+  const className = `w-full px-2 pt-1 pb-1.5 border focus-visible:outline-1 rounded-md ${textAlign}`;
   const placeholder = type === InputNumberType.INTEGRAL ? "0" : "0.0";
 
   return (
@@ -70,6 +62,10 @@ export const InputNumber = ({
         fontSize: textSize ? `${textSize}` : "unset",
       }}
       className={classNames(className, {
+        "border-gray-300 hover:border-gray-500 focus-visible:outline-gray-600":
+          isValid,
+        "border-2 border-red-400 hover:border-red-500 focus-visible:outline-red-600 ":
+          !isValid,
         "leading-none": leadingNone,
         "leading-normal": !leadingNone,
       })}
diff --git a/dcapal-frontend/src/components/exportBtn.js b/dcapal-frontend/src/components/exportBtn.js
index 0752aff9..c270dd20 100644
--- a/dcapal-frontend/src/components/exportBtn.js
+++ b/dcapal-frontend/src/components/exportBtn.js
@@ -1,10 +1,25 @@
 import React from "react";
 import { useSelector } from "react-redux";
 import { Step } from "../app/appSlice";
-import { aclassToString } from "./allocationFlow/portfolioStep/portfolioSlice";
+import {
+  aclassToString,
+  feeTypeToString,
+} from "./allocationFlow/portfolioStep/portfolioSlice";
 
 const pad = (n) => `${n}`.padStart(2, "0");
 
+const toExportedFees = (fees) => {
+  if (!fees) return null;
+
+  return {
+    ...fees,
+    feeStructure: {
+      ...fees.feeStructure,
+      type: feeTypeToString(fees.feeStructure.type),
+    },
+  };
+};
+
 const exportPfolio = (pfolio) => {
   const assets = Object.values(pfolio.assets).map((a) => ({
     symbol: a.symbol,
@@ -17,9 +32,10 @@ const exportPfolio = (pfolio) => {
     amount: a.amount,
     weight: a.weight,
     targetWeight: a.targetWeight,
+    fees: a.fees != null ? toExportedFees(a.fees) : undefined,
   }));
 
-  const data = { quoteCcy: pfolio.quoteCcy, assets: assets };
+  const data = { quoteCcy: pfolio.quoteCcy, fees: pfolio.fees, assets: assets };
   const jsonString = `data:text/json;chatset=utf-8,${encodeURIComponent(
     JSON.stringify(data, null, 2)
   )}`;
@@ -40,10 +56,17 @@ const exportPfolio = (pfolio) => {
 export const ExportBtn = () => {
   const step = useSelector((state) => state.app.allocationFlowStep);
 
-  const pfolio = useSelector((state) => ({
-    assets: state.pfolio.assets,
-    quoteCcy: state.pfolio.quoteCcy,
-  }));
+  const assets = useSelector((state) => state.pfolio.assets);
+  const quoteCcy = useSelector((state) => state.pfolio.quoteCcy);
+  const fees = useSelector((state) => state.pfolio.fees);
+
+  const exportedFees = toExportedFees(fees);
+
+  const pfolio = {
+    assets: assets,
+    quoteCcy: quoteCcy,
+    fees: exportedFees,
+  };
 
   const isDisplay =
     step && step === Step.PORTFOLIO && Object.keys(pfolio.assets).length > 0;
diff --git a/dcapal-frontend/src/utils/index.js b/dcapal-frontend/src/utils/index.js
index cc00770a..32f272ec 100644
--- a/dcapal-frontend/src/utils/index.js
+++ b/dcapal-frontend/src/utils/index.js
@@ -8,3 +8,18 @@ export const roundDecimals = (a, n) => {
 
 export const roundAmount = (a) => roundDecimals(a, 2);
 export const roundPrice = (a) => roundDecimals(a, 4);
+
+export const replacer = (key, value) => {
+  if (value instanceof Map) {
+    return {
+      dataType: "Map",
+      value: Array.from(value.entries()), // or with spread: value: [...value]
+    };
+  } else {
+    return value;
+  }
+};
+
+export const mapValues = (obj, func) => {
+  return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, func(v)]));
+};
diff --git a/dcapal-frontend/src/workers/solver.js b/dcapal-frontend/src/workers/solver.js
index cb929bd1..b2421fb8 100644
--- a/dcapal-frontend/src/workers/solver.js
+++ b/dcapal-frontend/src/workers/solver.js
@@ -6,19 +6,23 @@ const buildProblemInput = (
   assets,
   pfolioCcy,
   isBuyOnly,
-  isAdvancedAlgorithm
+  isAdvancedAlgorithm,
+  fees
 ) => {
   if (isAdvancedAlgorithm) {
     return {
+      type: "advanced",
       budget: budget,
       pfolio_ccy: pfolioCcy,
       assets: {
         ...assets,
       },
       is_buy_only: isBuyOnly,
+      fees: fees,
     };
   } else {
     return {
+      type: "basic",
       budget: budget,
       assets: {
         ...assets,
@@ -34,7 +38,8 @@ expose({
     assets,
     pfolioCcy,
     isBuyOnly,
-    isAdvancedAlgorithm
+    isAdvancedAlgorithm,
+    fees
   ) {
     if (Number.isNaN(budget) || budget < 0) return null;
     if (!assets || Object.keys(assets).length === 0) return null;
@@ -44,7 +49,8 @@ expose({
       assets,
       pfolioCcy,
       isBuyOnly,
-      isAdvancedAlgorithm
+      isAdvancedAlgorithm,
+      fees
     );
 
     const handle = Solver.build_problem(input);
diff --git a/dcapal-optimizer-wasm/Cargo.toml b/dcapal-optimizer-wasm/Cargo.toml
index 247c2694..0e1c87e4 100644
--- a/dcapal-optimizer-wasm/Cargo.toml
+++ b/dcapal-optimizer-wasm/Cargo.toml
@@ -24,11 +24,12 @@ serde-wasm-bindgen = "0.5.0"
 wasm-bindgen-futures = "0.4.32"
 futures = "0.3.23"
 anyhow = "1.0.62"
+thiserror = "1"
 lazy_static = "1.4.0"
 rand = "0.8.5"
 getrandom = { version = "0.2.7", features = ["js"] }
-rust_decimal = { version = "1.29.0", features = ["serde-with-float"] }
-rust_decimal_macros = "1.29.0"
+rust_decimal = { version = "1.32.0", features = ["serde-with-float"] }
+rust_decimal_macros = "1.32.0"
 
 [dev-dependencies]
 wasm-bindgen-test = "0.3.13"
diff --git a/dcapal-optimizer-wasm/src/lib.rs b/dcapal-optimizer-wasm/src/lib.rs
index bda7414e..de901678 100644
--- a/dcapal-optimizer-wasm/src/lib.rs
+++ b/dcapal-optimizer-wasm/src/lib.rs
@@ -1,7 +1,10 @@
 pub mod optimize;
 mod utils;
 
-use optimize::{advanced, basic};
+use optimize::{
+    advanced::{self, TheoreticalAllocation},
+    basic, FeeStructure, FeeStructureFixed, FeeStructureVariable, TransactionFees,
+};
 
 use rand::{distributions, Rng};
 use rust_decimal::{
@@ -10,14 +13,14 @@ use rust_decimal::{
 };
 use serde::{Deserialize, Serialize};
 use std::{collections::HashMap, sync::Mutex};
-use utils::{parse_amount, parse_shares, parse_weight};
+use utils::{parse_amount, parse_percentage, parse_shares};
 use wasm_bindgen::prelude::*;
 
 #[macro_use]
 extern crate lazy_static;
 
 const AMOUNT_DECIMALS: u32 = 4;
-const WEIGHT_DECIMALS: u32 = 6;
+const PERCENTAGE_DECIMALS: u32 = 6;
 const SHARES_DECIMALS: u32 = 8;
 
 lazy_static! {
@@ -129,10 +132,17 @@ impl Solver {
             .map(|(aid, v)| (aid.clone(), v.shares.to_f64().unwrap()))
             .collect();
 
+        let theo_allocs = solution
+            .assets
+            .iter()
+            .filter_map(|(aid, v)| v.theo_alloc.clone().map(|t| (aid.clone(), t.into())))
+            .collect();
+
         let js_solution = JsAdvancedSolution {
             budget_left,
             amounts,
             shares,
+            theo_allocs,
         };
         Ok(serde_wasm_bindgen::to_value(&js_solution).unwrap())
     }
@@ -176,10 +186,28 @@ pub struct JsAdvancedSolution {
     pub budget_left: f64,
     pub amounts: HashMap<String, f64>,
     pub shares: HashMap<String, f64>,
+    pub theo_allocs: HashMap<String, JsTheoreticalAllocation>,
+}
+
+#[derive(Debug, Clone, Serialize, Default)]
+pub struct JsTheoreticalAllocation {
+    pub shares: f64,
+    pub amount: f64,
+    pub fees: f64,
+}
+
+impl From<TheoreticalAllocation> for JsTheoreticalAllocation {
+    fn from(value: TheoreticalAllocation) -> Self {
+        Self {
+            shares: value.shares.to_f64().unwrap(),
+            amount: value.amount.to_f64().unwrap(),
+            fees: value.fees.to_f64().unwrap(),
+        }
+    }
 }
 
 #[derive(Serialize, Deserialize)]
-#[serde(untagged)]
+#[serde(tag = "type", rename_all = "camelCase")]
 pub enum JsProblemOptions {
     Advanced(JsAdvancedOptions),
     Basic(JsBasicOptions),
@@ -191,6 +219,7 @@ pub struct JsAdvancedOptions {
     pub pfolio_ccy: String,
     pub assets: HashMap<String, JsAdvancedAsset>,
     pub is_buy_only: bool,
+    pub fees: Option<JsTransactionFees>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -200,6 +229,37 @@ pub struct JsAdvancedAsset {
     pub price: f64,
     pub target_weight: f64,
     pub is_whole_shares: bool,
+    pub fees: Option<JsTransactionFees>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct JsTransactionFees {
+    /// Maximum acceptable fee impact (as a rate, in [0..1] range)
+    pub max_fee_impact: Option<f64>,
+    pub fee_structure: JsFeeStructure,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum JsFeeStructure {
+    ZeroFee,
+    Fixed(JsFeeStructureFixed),
+    Variable(JsFeeStructureVariable),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct JsFeeStructureFixed {
+    pub fee_amount: Option<f64>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct JsFeeStructureVariable {
+    pub min_fee: Option<f64>,
+    pub max_fee: Option<f64>,
+    pub fee_rate: Option<f64>,
 }
 
 impl TryFrom<JsAdvancedOptions> for advanced::ProblemOptions {
@@ -245,12 +305,19 @@ impl TryFrom<JsAdvancedOptions> for advanced::ProblemOptions {
             ));
         }
 
+        let fees = options
+            .fees
+            .map(TransactionFees::try_from)
+            .transpose()?
+            .unwrap_or_default();
+
         Ok(advanced::ProblemOptions {
             pfolio_ccy: options.pfolio_ccy,
             current_pfolio_amount: current_total,
             assets,
             budget,
             is_buy_only: options.is_buy_only,
+            fees,
         })
     }
 }
@@ -265,6 +332,7 @@ impl TryFrom<JsAdvancedAsset> for advanced::ProblemAsset {
             price,
             target_weight,
             is_whole_shares,
+            fees,
         } = asset;
 
         if symbol.is_empty() {
@@ -305,12 +373,92 @@ impl TryFrom<JsAdvancedAsset> for advanced::ProblemAsset {
             shares
         };
 
+        let fees = fees.map(TransactionFees::try_from).transpose()?;
+
         Ok(advanced::ProblemAsset {
             symbol,
             shares: parse_shares(shares),
             price: parse_amount(price),
-            target_weight: parse_weight(target_weight),
+            target_weight: parse_percentage(target_weight),
             is_whole_shares,
+            fees,
+        })
+    }
+}
+
+impl TryFrom<JsTransactionFees> for TransactionFees {
+    type Error = String;
+
+    fn try_from(value: JsTransactionFees) -> Result<Self, Self::Error> {
+        if let Some(max) = value.max_fee_impact {
+            if !(0. ..=1.).contains(&max) {
+                return Err(format!(
+                    "Invalid max_fee_impact ({max}). Must be in [0, 1] range"
+                ));
+            }
+        }
+
+        let max_fee_impact = value
+            .max_fee_impact
+            .map(parse_percentage)
+            .unwrap_or_else(TransactionFees::default_max_fee_impact);
+
+        Ok(Self {
+            max_fee_impact,
+            fee_structure: value.fee_structure.try_into()?,
+        })
+    }
+}
+
+impl TryFrom<JsFeeStructure> for FeeStructure {
+    type Error = String;
+
+    fn try_from(value: JsFeeStructure) -> Result<Self, Self::Error> {
+        Ok(match value {
+            JsFeeStructure::ZeroFee => Self::default(),
+            JsFeeStructure::Fixed(fee) => FeeStructure::Fixed(fee.try_into()?),
+            JsFeeStructure::Variable(fee) => FeeStructure::Variable(fee.try_into()?),
+        })
+    }
+}
+
+impl TryFrom<JsFeeStructureFixed> for FeeStructureFixed {
+    type Error = String;
+
+    fn try_from(value: JsFeeStructureFixed) -> Result<Self, Self::Error> {
+        if let Some(amount) = value.fee_amount {
+            if amount < 0. {
+                return Err(format!("Invalid fee_amount ({amount}). Must be positive"));
+            }
+        }
+
+        let fee_amount = value.fee_amount.map(parse_amount).unwrap_or(Decimal::ZERO);
+
+        Ok(Self { fee_amount })
+    }
+}
+
+impl TryFrom<JsFeeStructureVariable> for FeeStructureVariable {
+    type Error = String;
+
+    fn try_from(value: JsFeeStructureVariable) -> Result<Self, Self::Error> {
+        if let Some(rate) = value.fee_rate {
+            if !(0. ..=1.).contains(&rate) {
+                return Err(format!(
+                    "Invalid fee_rate ({rate}). Must be in [0, 1] range"
+                ));
+            }
+        }
+
+        let fee_rate = value
+            .fee_rate
+            .map(parse_percentage)
+            .unwrap_or(Decimal::ZERO);
+
+        Ok(Self {
+            min_fee: value.min_fee.map(parse_amount),
+            max_fee: value.max_fee.map(parse_amount),
+            fee_rate,
         })
     }
 }
@@ -419,7 +567,7 @@ impl TryFrom<JsProblemAsset> for basic::ProblemAsset {
 
         Ok(basic::ProblemAsset {
             symbol,
-            target_weight: parse_weight(target_weight),
+            target_weight: parse_percentage(target_weight),
             current_amount: parse_amount(current_amount),
         })
     }
diff --git a/dcapal-optimizer-wasm/src/optimize/advanced.rs b/dcapal-optimizer-wasm/src/optimize/advanced.rs
index 733d3b9f..253ceeb7 100644
--- a/dcapal-optimizer-wasm/src/optimize/advanced.rs
+++ b/dcapal-optimizer-wasm/src/optimize/advanced.rs
@@ -2,8 +2,11 @@ use std::collections::HashMap;
 
 use log::debug;
 use rust_decimal::prelude::*;
+use rust_decimal_macros::dec;
 
-use crate::{AMOUNT_DECIMALS, SHARES_DECIMALS, WEIGHT_DECIMALS};
+use crate::{AMOUNT_DECIMALS, PERCENTAGE_DECIMALS, SHARES_DECIMALS};
+
+use super::TransactionFees;
 
 #[derive(Debug, Clone)]
 pub struct ProblemOptions {
@@ -12,6 +15,7 @@ pub struct ProblemOptions {
     pub assets: HashMap<String, ProblemAsset>,
     pub budget: Decimal,
     pub is_buy_only: bool,
+    pub fees: TransactionFees,
 }
 
 #[derive(Debug, Clone)]
@@ -21,6 +25,7 @@ pub struct ProblemAsset {
     pub price: Decimal,
     pub target_weight: Decimal,
     pub is_whole_shares: bool,
+    pub fees: Option<TransactionFees>,
 }
 
 #[derive(Debug, Clone)]
@@ -36,6 +41,26 @@ pub struct Asset {
     pub shares: Decimal,
     pub amount: Decimal,
     pub weight: Decimal,
+    pub fees: Option<TransactionFees>,
+    pub theo_alloc: Option<TheoreticalAllocation>,
+    pub state: SolutionState,
+}
+
+#[derive(Debug, Clone)]
+pub struct TheoreticalAllocation {
+    pub shares: Decimal,
+    pub amount: Decimal,
+    pub fees: Decimal,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SolutionState {
+    Open,
+    Overallocated,
+    FullyAllocated,
+    PriceTooHigh,
+    FeesTooHigh,
+    TieBreaker,
 }
 
 impl Asset {
@@ -46,6 +71,7 @@ impl Asset {
             price,
             target_weight,
             is_whole_shares,
+            fees,
         } = asset;
 
         let pfolio_amount = current_pfolio_amount + budget;
@@ -53,7 +79,7 @@ impl Asset {
         let current_shares = shares;
         let current_amount = (price * shares).round_dp(AMOUNT_DECIMALS);
         let current_weight = if current_pfolio_amount > Decimal::ZERO {
-            (current_amount / current_pfolio_amount).round_dp(WEIGHT_DECIMALS)
+            (current_amount / current_pfolio_amount).round_dp(PERCENTAGE_DECIMALS)
         } else {
             Decimal::ZERO
         };
@@ -73,8 +99,31 @@ impl Asset {
             shares,
             amount,
             weight,
+            fees,
+            theo_alloc: None,
+            state: SolutionState::Open,
         }
     }
+
+    pub fn compute_fee(&self, amount: &Decimal, general_fees: &TransactionFees) -> Decimal {
+        if let Some(ref fee) = self.fees {
+            fee.fee_structure.compute_fee(amount)
+        } else {
+            general_fees.fee_structure.compute_fee(amount)
+        }
+    }
+
+    pub fn get_max_fee_impact(&self, general_fees: &TransactionFees) -> Decimal {
+        if let Some(ref fee) = self.fees {
+            fee.max_fee_impact
+        } else {
+            general_fees.max_fee_impact
+        }
+    }
+
+    pub fn get_allocated_amount(&self) -> Decimal {
+        self.amount - self.current_amount
+    }
 }
 
 pub struct Problem {
@@ -118,11 +167,14 @@ impl Problem {
         let pfolio_amount = self.options.current_pfolio_amount + self.options.budget;
 
         let sold_amount = if self.options.is_buy_only {
+            close_over_allocated_assets(&mut solution.assets);
             Decimal::ZERO
         } else {
             sell_over_allocated_assets(&mut solution, pfolio_amount)
         };
 
+        let pfolio_fees = &self.options.fees;
+
         // Budget available to allocate
         let mut budget_left = self.options.budget + sold_amount;
 
@@ -135,50 +187,95 @@ impl Problem {
         let mut w_sum: Decimal = open_assets.iter().map(|a| a.target_weight).sum();
         let mut adjusted_weights = open_assets
             .iter()
-            .map(|a| (a.target_weight / w_sum).round_dp(WEIGHT_DECIMALS))
+            .map(|a| (a.target_weight / w_sum).round_dp(PERCENTAGE_DECIMALS))
             .collect::<Vec<Decimal>>();
 
         let mut step = 0;
-        while budget_left > Decimal::ZERO && !open_assets.is_empty() {
+        while !open_assets.is_empty() {
             debug!(
                 "[Step {}] budget_left={} open_assets={:?} w_sum={} adjusted_weights={:?}",
                 step, budget_left, open_assets, w_sum, adjusted_weights
             );
 
             let mut budget_left_next = budget_left;
-
-            // Allocate budget depending on target weight
             let mut is_all_unallocated = true;
-            for (i, asset) in open_assets.iter_mut().enumerate() {
-                let w_i = adjusted_weights[i];
-                let allocated = amount_to_allocate(asset, budget_left, w_i);
-                let allocated_shares = shares_to_allocate(asset, allocated);
 
-                if allocated_shares > Decimal::ZERO {
-                    is_all_unallocated = false;
+            if !is_negiglible(&budget_left) {
+                // Allocate budget depending on target weight
+                for (i, asset) in open_assets.iter_mut().enumerate() {
+                    let w_i = adjusted_weights[i];
+                    let allocated = amount_to_allocate(asset, budget_left, w_i);
+                    let allocated_shares = shares_to_allocate(asset, allocated);
+
+                    if allocated_shares > Decimal::ZERO {
+                        is_all_unallocated = false;
+                    }
+
+                    // Update allocated amount depending on shares allocated
+                    let allocated = (allocated_shares * asset.price).round_dp(AMOUNT_DECIMALS);
+
+                    // Update solution values
+                    asset.amount += allocated;
+                    asset.shares += allocated_shares;
+                    asset.weight = (asset.amount / pfolio_amount).round_dp(PERCENTAGE_DECIMALS);
+                    budget_left_next -= allocated;
                 }
+            }
 
-                // Update allocated amount depending on shares allocated
-                let allocated = (allocated_shares * asset.price).round_dp(AMOUNT_DECIMALS);
+            // Check asset constraints
+            let mut freed_budget =
+                check_fully_allocated_assets(&mut open_assets, &budget_left, pfolio_fees);
 
-                // Update solution values
-                asset.amount += allocated;
-                asset.shares += allocated_shares;
-                asset.weight = (asset.amount / pfolio_amount).round_dp(WEIGHT_DECIMALS);
-                budget_left_next -= allocated;
+            // If previous step freed some budget, do another round of allocation unconditionally.
+            // Otherwise, try to break ties or exit
+            if freed_budget == Decimal::ZERO {
+                // Unblock ties if necessary
+                if !is_negiglible(&budget_left) && is_all_unallocated {
+                    freed_budget += unblock_ties(&mut open_assets, pfolio_fees);
+                }
+
+                // Check remaining budget
+                let was_negligible = is_negiglible(&budget_left);
+                budget_left = budget_left_next + freed_budget;
+                if was_negligible && is_negiglible(&budget_left) {
+                    // Check fee impact for open assets
+                    freed_budget = Decimal::ZERO;
+                    for asset in &mut open_assets {
+                        if is_fee_impact_too_high(asset, pfolio_fees) {
+                            freed_budget += deallocate_asset_fee_too_high(asset, pfolio_fees);
+                        }
+                    }
+
+                    budget_left += freed_budget;
+                    if is_negiglible(&budget_left) {
+                        for asset in &mut open_assets {
+                            asset.state = SolutionState::FullyAllocated;
+                        }
+                        break;
+                    }
+                }
+            } else {
+                budget_left = budget_left_next + freed_budget
             }
 
-            // Update open_assets and rescaled weights
-            check_fully_allocated_assets(&mut open_assets, budget_left);
-            if is_all_unallocated {
-                unblock_ties(&mut open_assets, budget_left);
+            // Refresh open_assets
+            if freed_budget > Decimal::ZERO {
+                debug!(
+                    "[Step {step}] Before refresh: budget_left={budget_left} freed_budget={freed_budget} count={} open_assets={open_assets:?}",
+                    open_assets.len()
+                );
+                open_assets = refresh_open_assets(&mut solution.assets, &budget_left);
+                debug!(
+                    "[Step {step}] After refresh: budget_left={budget_left} freed_budget={freed_budget} count={} open_assets={open_assets:?}",
+                    open_assets.len()
+                );
             }
 
-            budget_left = budget_left_next;
+            // Update open_assets rescaled weights
             w_sum = open_assets.iter().map(|a| a.target_weight).sum();
             adjusted_weights = open_assets
                 .iter()
-                .map(|a| (a.target_weight / w_sum).round_dp(WEIGHT_DECIMALS))
+                .map(|a| (a.target_weight / w_sum).round_dp(PERCENTAGE_DECIMALS))
                 .collect::<Vec<Decimal>>();
 
             step += 1;
@@ -191,7 +288,7 @@ impl Problem {
 
         // Reconcile solution weights
         for asset in solution.assets.values_mut() {
-            asset.weight = (asset.amount / pfolio_amount).round_dp(WEIGHT_DECIMALS);
+            asset.weight = (asset.amount / pfolio_amount).round_dp(PERCENTAGE_DECIMALS);
         }
 
         solution.is_solved = true;
@@ -203,13 +300,46 @@ impl Problem {
     }
 }
 
+fn is_negiglible(budget: &Decimal) -> bool {
+    *budget < dec!(0.01)
+}
+
+fn refresh_open_assets<'a>(
+    assets: &'a mut HashMap<String, Asset>,
+    budget_left: &Decimal,
+) -> Vec<&'a mut Asset> {
+    assets
+        .values_mut()
+        .filter(|a| {
+            a.state == SolutionState::Open
+                || a.state == SolutionState::TieBreaker
+                || (a.state == SolutionState::PriceTooHigh
+                    && (!a.is_whole_shares || a.price <= *budget_left))
+        })
+        .map(|a| {
+            a.state = SolutionState::Open;
+            a
+        })
+        .collect::<_>()
+}
+
+fn close_over_allocated_assets(assets: &mut HashMap<String, Asset>) {
+    for asset in assets.values_mut() {
+        if asset.current_amount > asset.target_amount {
+            asset.state = SolutionState::Overallocated;
+        }
+    }
+}
+
 fn sell_over_allocated_assets(solution: &mut Solution, pfolio_amount: Decimal) -> Decimal {
     let mut sold_amount = Decimal::ZERO;
     for asset in solution.assets.values_mut() {
-        if asset.current_weight <= asset.target_weight {
+        if asset.current_amount <= asset.target_amount {
             continue;
         }
 
+        asset.state = SolutionState::Overallocated;
+
         let overallocated = asset.current_amount - asset.target_amount;
         let sell_shares = shares_to_allocate(asset, overallocated);
         if sell_shares.is_zero() {
@@ -222,7 +352,7 @@ fn sell_over_allocated_assets(solution: &mut Solution, pfolio_amount: Decimal) -
         // Update solution values
         asset.amount -= sell_amount;
         asset.shares -= sell_shares;
-        asset.weight = (asset.amount / pfolio_amount).round_dp(WEIGHT_DECIMALS);
+        asset.weight = (asset.amount / pfolio_amount).round_dp(PERCENTAGE_DECIMALS);
         sold_amount += sell_amount;
     }
 
@@ -233,7 +363,7 @@ fn sell_over_allocated_assets(solution: &mut Solution, pfolio_amount: Decimal) -
 fn under_allocated_view(assets: &mut HashMap<String, Asset>) -> Vec<&mut Asset> {
     assets
         .values_mut()
-        .filter_map(|a| (a.current_amount <= a.target_amount).then_some(a))
+        .filter_map(|a| (a.state == SolutionState::Open).then_some(a))
         .collect::<Vec<&mut Asset>>()
 }
 
@@ -253,39 +383,57 @@ fn shares_to_allocate(asset: &Asset, allocated_amount: Decimal) -> Decimal {
     }
 }
 
-/// Remove fully allocated assets
-fn check_fully_allocated_assets(open_assets: &mut Vec<&mut Asset>, budget_left: Decimal) {
+/// Remove fully allocated assets. Returns budget freed from assets with too high fee impact.
+fn check_fully_allocated_assets(
+    open_assets: &mut Vec<&mut Asset>,
+    budget_left: &Decimal,
+    fees: &TransactionFees,
+) -> Decimal {
+    let mut freed_budget = Decimal::ZERO;
+
     // Remove fully-allocated assets
     let mut i = 0;
     while i < open_assets.len() {
-        let to_remove = {
-            let asset = &open_assets[i];
+        let asset = &mut open_assets[i];
 
+        let to_remove = || -> bool {
             // Fully allocated
-            let is_fully_allocated = asset.amount >= asset.target_amount;
+            if asset.amount >= asset.target_amount {
+                asset.state = SolutionState::FullyAllocated;
+                return true;
+            }
 
             // Cannot allocate more -- Budget left higher than asset price
-            let no_more_shares = asset.is_whole_shares && asset.price > budget_left;
+            if asset.is_whole_shares && asset.price > *budget_left {
+                asset.state = SolutionState::PriceTooHigh;
+                return true;
+            }
 
-            is_fully_allocated || no_more_shares
-        };
+            false
+        }();
 
         if to_remove {
+            if is_fee_impact_too_high(asset, fees) {
+                freed_budget += deallocate_asset_fee_too_high(asset, fees);
+            }
             open_assets.remove(i);
         } else {
             i += 1;
         }
     }
+
+    freed_budget
 }
 
-fn unblock_ties(open_assets: &mut Vec<&mut Asset>, budget_left: Decimal) {
+fn unblock_ties(open_assets: &mut Vec<&mut Asset>, fees: &TransactionFees) -> Decimal {
+    let mut freed_budget = Decimal::ZERO;
     let mut not_enough_budget_idx = None;
     let mut min_target_distance = Decimal::MAX;
 
-    // Find asset closest to target allocation we don't have budget for
+    // Otherwise, just find the asset closest to target allocation to remove
     for (i, asset) in open_assets.iter().enumerate() {
         let target_distance = asset.target_amount - asset.amount;
-        if asset.price > budget_left && target_distance < min_target_distance {
+        if target_distance < min_target_distance {
             // Cannot allocate more to this asset -- not enough budget
             min_target_distance = target_distance;
             not_enough_budget_idx = Some(i);
@@ -293,24 +441,52 @@ fn unblock_ties(open_assets: &mut Vec<&mut Asset>, budget_left: Decimal) {
     }
 
     if let Some(idx) = not_enough_budget_idx {
-        open_assets.remove(idx);
-        return;
-    }
+        let asset = &mut open_assets[idx];
+        asset.state = SolutionState::TieBreaker;
 
-    // Otherwise, just find the asset closest to target allocation to remove
-    min_target_distance = Decimal::MAX;
-    for (i, asset) in open_assets.iter().enumerate() {
-        let target_distance = asset.target_amount - asset.amount;
-        if target_distance < min_target_distance {
-            // Cannot allocate more to this asset -- not enough budget
-            min_target_distance = target_distance;
-            not_enough_budget_idx = Some(i);
+        if is_fee_impact_too_high(asset, fees) {
+            freed_budget = deallocate_asset_fee_too_high(asset, fees);
         }
-    }
 
-    if let Some(idx) = not_enough_budget_idx {
         open_assets.remove(idx);
     }
+
+    freed_budget
+}
+
+fn is_fee_impact_too_high(asset: &Asset, general_fees: &TransactionFees) -> bool {
+    let allocated = asset.get_allocated_amount();
+    if allocated <= Decimal::ZERO {
+        return false;
+    }
+
+    let fees = asset.compute_fee(&allocated, general_fees);
+    let impact = fees / allocated;
+
+    debug!(
+        "allocated={allocated} fees={fees} impact={impact} max_fee_impact={}",
+        asset.get_max_fee_impact(general_fees)
+    );
+
+    impact > asset.get_max_fee_impact(general_fees)
+}
+
+fn deallocate_asset_fee_too_high(asset: &mut Asset, general_fees: &TransactionFees) -> Decimal {
+    let allocated = asset.get_allocated_amount();
+
+    asset.theo_alloc.replace(TheoreticalAllocation {
+        shares: asset.shares,
+        amount: asset.amount,
+        fees: asset.compute_fee(&allocated, general_fees),
+    });
+
+    asset.amount = asset.current_amount;
+    asset.shares = asset.current_shares;
+    asset.weight = asset.current_weight;
+
+    asset.state = SolutionState::FeesTooHigh;
+
+    allocated
 }
 
 #[cfg(test)]
@@ -464,6 +640,7 @@ mod tests {
                     price: dec!(5.3),
                     target_weight: dec!(0.6),
                     is_whole_shares: true,
+                    fees: None,
                 },
             ),
             (
@@ -474,6 +651,7 @@ mod tests {
                     price: dec!(1.3),
                     target_weight: dec!(0.4),
                     is_whole_shares: true,
+                    fees: None,
                 },
             ),
         ]);
@@ -489,6 +667,7 @@ mod tests {
             assets,
             budget,
             is_buy_only,
+            fees: TransactionFees::default(),
         };
 
         (Problem::new(options), vec![vwce, aggh])
@@ -517,6 +696,7 @@ mod tests {
                     price: vwce_price,
                     target_weight: dec!(0.6),
                     is_whole_shares: true,
+                    fees: None,
                 },
             ),
             (
@@ -527,6 +707,7 @@ mod tests {
                     price: dec!(1.3),
                     target_weight: dec!(0.4),
                     is_whole_shares: true,
+                    fees: None,
                 },
             ),
         ]);
@@ -542,6 +723,7 @@ mod tests {
             assets,
             budget,
             is_buy_only,
+            fees: TransactionFees::default(),
         };
 
         (Problem::new(options), vec![vwce, aggh])
diff --git a/dcapal-optimizer-wasm/src/optimize/basic.rs b/dcapal-optimizer-wasm/src/optimize/basic.rs
index 0ce10e0e..8869b736 100644
--- a/dcapal-optimizer-wasm/src/optimize/basic.rs
+++ b/dcapal-optimizer-wasm/src/optimize/basic.rs
@@ -4,7 +4,7 @@ use log::debug;
 use minilp::{ComparisonOp, OptimizationDirection, Variable};
 use rust_decimal::prelude::*;
 
-use crate::{utils::parse_amount, AMOUNT_DECIMALS, WEIGHT_DECIMALS};
+use crate::{utils::parse_amount, AMOUNT_DECIMALS, PERCENTAGE_DECIMALS};
 
 #[derive(Debug, Clone)]
 pub struct ProblemOptions {
@@ -128,7 +128,7 @@ pub fn refine_solution(problem: &Problem, vars: &HashMap<String, f64>) -> HashMa
         let mut w_sum: Decimal = under_allocated.iter().map(|a| a.target_weight).sum();
         let mut adjusted_weights = under_allocated
             .iter()
-            .map(|a| (a.target_weight / w_sum).round_dp(WEIGHT_DECIMALS))
+            .map(|a| (a.target_weight / w_sum).round_dp(PERCENTAGE_DECIMALS))
             .collect::<Vec<Decimal>>();
 
         debug!(
@@ -145,7 +145,8 @@ pub fn refine_solution(problem: &Problem, vars: &HashMap<String, f64>) -> HashMa
                         .round_dp(AMOUNT_DECIMALS);
 
                 asset.current_amount += allocated_amount;
-                asset.current_weight = (asset.current_amount / budget).round_dp(WEIGHT_DECIMALS);
+                asset.current_weight =
+                    (asset.current_amount / budget).round_dp(PERCENTAGE_DECIMALS);
                 leftover_next += w_i * leftover - allocated_amount;
             }
 
@@ -167,7 +168,7 @@ pub fn refine_solution(problem: &Problem, vars: &HashMap<String, f64>) -> HashMa
             w_sum = under_allocated.iter().map(|a| a.target_weight).sum();
             adjusted_weights = under_allocated
                 .iter()
-                .map(|a| (a.target_weight / w_sum).round_dp(WEIGHT_DECIMALS))
+                .map(|a| (a.target_weight / w_sum).round_dp(PERCENTAGE_DECIMALS))
                 .collect::<Vec<Decimal>>();
 
             debug!(
@@ -224,9 +225,9 @@ fn parse_assets(options: &ProblemOptions, vars: &HashMap<String, f64>) -> HashMa
             let current_amount = asset.current_amount;
             let target_weight = asset.target_weight;
             let target_amount = (target_weight * budget).round_dp(AMOUNT_DECIMALS);
-            let current_weight = (current_amount / budget).round_dp(WEIGHT_DECIMALS);
+            let current_weight = (current_amount / budget).round_dp(PERCENTAGE_DECIMALS);
             let solution_amount = parse_amount(*solution_amount);
-            let solution_weight = (solution_amount / budget).round_dp(WEIGHT_DECIMALS);
+            let solution_weight = (solution_amount / budget).round_dp(PERCENTAGE_DECIMALS);
 
             (
                 aid.clone(),
diff --git a/dcapal-optimizer-wasm/src/optimize/mod.rs b/dcapal-optimizer-wasm/src/optimize/mod.rs
index f0f34789..3af8e3ea 100644
--- a/dcapal-optimizer-wasm/src/optimize/mod.rs
+++ b/dcapal-optimizer-wasm/src/optimize/mod.rs
@@ -1,2 +1,94 @@
 pub mod advanced;
 pub mod basic;
+
+use rust_decimal::Decimal;
+use serde::{Deserialize, Serialize};
+
+use crate::AMOUNT_DECIMALS;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TransactionFees {
+    #[serde(
+        with = "rust_decimal::serde::float",
+        default = "TransactionFees::default_max_fee_impact"
+    )]
+    /// Maximum acceptable fee impact (as a rate, in [0..1] range)
+    pub max_fee_impact: Decimal,
+    #[serde(default)]
+    pub fee_structure: FeeStructure,
+}
+
+impl Default for TransactionFees {
+    fn default() -> Self {
+        Self {
+            max_fee_impact: Self::default_max_fee_impact(),
+            fee_structure: Default::default(),
+        }
+    }
+}
+
+impl TransactionFees {
+    pub fn default_max_fee_impact() -> Decimal {
+        Decimal::MAX
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum FeeStructure {
+    Fixed(FeeStructureFixed),
+    Variable(FeeStructureVariable),
+}
+
+impl FeeStructure {
+    pub fn compute_fee(&self, amount: &Decimal) -> Decimal {
+        match self {
+            FeeStructure::Fixed(fee) => {
+                if *amount > Decimal::ZERO {
+                    fee.fee_amount
+                } else {
+                    Decimal::ZERO
+                }
+            }
+            FeeStructure::Variable(fee) => fee.compute_fee(amount),
+        }
+    }
+}
+
+impl Default for FeeStructure {
+    fn default() -> Self {
+        FeeStructure::Fixed(FeeStructureFixed {
+            fee_amount: Decimal::ZERO,
+        })
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeeStructureFixed {
+    #[serde(with = "rust_decimal::serde::float")]
+    pub fee_amount: Decimal,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeeStructureVariable {
+    #[serde(with = "rust_decimal::serde::float_option")]
+    pub min_fee: Option<Decimal>,
+    #[serde(with = "rust_decimal::serde::float_option")]
+    pub max_fee: Option<Decimal>,
+    #[serde(with = "rust_decimal::serde::float")]
+    pub fee_rate: Decimal,
+}
+
+impl FeeStructureVariable {
+    pub fn compute_fee(&self, amount: &Decimal) -> Decimal {
+        (self.fee_rate * amount)
+            .clamp(
+                self.min_fee.unwrap_or(Decimal::ZERO),
+                self.max_fee.unwrap_or(Decimal::MAX),
+            )
+            .round_dp(AMOUNT_DECIMALS)
+    }
+}
diff --git a/dcapal-optimizer-wasm/src/utils.rs b/dcapal-optimizer-wasm/src/utils.rs
index b33a7762..38af1441 100644
--- a/dcapal-optimizer-wasm/src/utils.rs
+++ b/dcapal-optimizer-wasm/src/utils.rs
@@ -5,8 +5,8 @@ use rust_decimal::prelude::*;
 use rust_decimal::Decimal;
 
 use crate::AMOUNT_DECIMALS;
+use crate::PERCENTAGE_DECIMALS;
 use crate::SHARES_DECIMALS;
-use crate::WEIGHT_DECIMALS;
 
 static INIT: Once = Once::new();
 
@@ -41,8 +41,10 @@ pub fn parse_amount(amount: f64) -> Decimal {
     Decimal::from_f64(amount).unwrap().round_dp(AMOUNT_DECIMALS)
 }
 
-pub fn parse_weight(weight: f64) -> Decimal {
-    Decimal::from_f64(weight).unwrap().round_dp(WEIGHT_DECIMALS)
+pub fn parse_percentage(weight: f64) -> Decimal {
+    Decimal::from_f64(weight)
+        .unwrap()
+        .round_dp(PERCENTAGE_DECIMALS)
 }
 
 pub fn parse_shares(shares: f64) -> Decimal {


Test patch:
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 06d9b0ee..af0e8394 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -43,7 +43,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
 
@@ -65,7 +65,7 @@ jobs:
       - name: Test
         run: |
           cd dcapal-optimizer-wasm
-          RUST_LOG=dcapal_optimizer_wasm=debug cargo test -- --nocapture
+          RUST_LOG=info cargo test -- --nocapture
           wasm-pack test --headless --chrome
 
       - name: Build
@@ -79,9 +79,26 @@ jobs:
           name: dcapal-optimizer-wasm-pkg
           path: dcapal-optimizer-wasm/pkg
 
-  frontend-build-test:
+  frontend-check:
     runs-on: ubuntu-22.04
     needs: optimizer-build-test
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: 18
+          cache: "npm"
+          cache-dependency-path: dcapal-frontend/package-lock.json
+    
+      - name: Check formatting
+        run: |
+          cd dcapal-frontend
+          npm ci
+          npm run check
+
+  frontend-build-test:
+    runs-on: ubuntu-22.04
+    needs: [frontend-check, optimizer-build-test] 
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
diff --git a/dcapal-optimizer-wasm/tests/scenarios/BUGS-1-negligible-budget-left.json b/dcapal-optimizer-wasm/tests/scenarios/BUGS-1-negligible-budget-left.json
index ce2d9570..d4a109e5 100644
--- a/dcapal-optimizer-wasm/tests/scenarios/BUGS-1-negligible-budget-left.json
+++ b/dcapal-optimizer-wasm/tests/scenarios/BUGS-1-negligible-budget-left.json
@@ -106,10 +106,10 @@
     "expect": {
         "result": "solved",
         "solution": {
-            "VWCE.DE": {
-                "shares": 0,
-                "amount": 0,
-                "weight": 0
+            "VWCE.MI": {
+                "shares": 171,
+                "amount": 17170.11,
+                "weight": 0.579873
             }
         }
     }
diff --git a/dcapal-optimizer-wasm/tests/scenarios/BUGS-2-high-budget-left.json b/dcapal-optimizer-wasm/tests/scenarios/BUGS-2-high-budget-left.json
index 22a87b4d..09aa505a 100644
--- a/dcapal-optimizer-wasm/tests/scenarios/BUGS-2-high-budget-left.json
+++ b/dcapal-optimizer-wasm/tests/scenarios/BUGS-2-high-budget-left.json
@@ -104,13 +104,6 @@
         ]
     },
     "expect": {
-        "result": "solved",
-        "solution": {
-            "VWCE.DE": {
-                "shares": 0,
-                "amount": 0,
-                "weight": 0
-            }
-        }
+        "result": "solved"
     }
 }
\ No newline at end of file
diff --git a/dcapal-optimizer-wasm/tests/scenarios/BUGS-3-negligible-budget-left-fractional-shares.json b/dcapal-optimizer-wasm/tests/scenarios/BUGS-3-negligible-budget-left-fractional-shares.json
new file mode 100644
index 00000000..8ebb8e15
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/scenarios/BUGS-3-negligible-budget-left-fractional-shares.json
@@ -0,0 +1,109 @@
+{
+    "algorithm": "advanced",
+    "budget": 3200,
+    "isBuyOnly": true,
+    "portfolio": {
+        "quoteCcy": "eur",
+        "assets": [
+            {
+                "symbol": "VWCE.DE",
+                "name": "Vanguard FTSE All-World UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 100.44,
+                "qty": 178,
+                "amount": 17878.32,
+                "weight": 54.430179290541915,
+                "targetWeight": 56
+            },
+            {
+                "symbol": "ZPRV.DE",
+                "name": "SPDR MSCI USA Small Cap Value Weighted UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.22,
+                "qty": 36,
+                "amount": 1843.92,
+                "weight": 5.613776696994798,
+                "targetWeight": 7.2
+            },
+            {
+                "symbol": "ZPRX.DE",
+                "name": "SPDR MSCI Europe Small Cap Value Weighted UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 43.075,
+                "qty": 27,
+                "amount": 1163.025,
+                "weight": 3.5408058066631822,
+                "targetWeight": 4.8
+            },
+            {
+                "symbol": "EUNA.DE",
+                "name": "iShares Core Global Aggregate Bond UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 4.5472,
+                "qty": 1028,
+                "amount": 4674.5216,
+                "weight": 14.231485328907349,
+                "targetWeight": 12
+            },
+            {
+                "symbol": "EPRA.PA",
+                "name": "Amundi Index Solutions - Amundi Index FTSE EPRA NAREIT Global",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 56.532,
+                "qty": 14,
+                "amount": 791.448,
+                "weight": 2.409547235933847,
+                "targetWeight": 0
+            },
+            {
+                "symbol": "btc",
+                "name": "Bitcoin",
+                "aclass": "CRYPTO",
+                "baseCcy": "btc",
+                "provider": "DCAPal",
+                "price": 24104.6465,
+                "qty": 0.071039,
+                "amount": 1712.3699827135001,
+                "weight": 5.213275362049564,
+                "targetWeight": 5.6
+            },
+            {
+                "symbol": "eth",
+                "name": "Ethereum",
+                "aclass": "CRYPTO",
+                "baseCcy": "eth",
+                "provider": "DCAPal",
+                "price": 1527.6862,
+                "qty": 0.46132,
+                "amount": 704.752197784,
+                "weight": 2.145603640654523,
+                "targetWeight": 2.4
+            },
+            {
+                "symbol": "IS3R.DE",
+                "name": "iShares Edge MSCI World Momentum Factor UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.62,
+                "qty": 79,
+                "amount": 4077.98,
+                "weight": 12.41532663825483,
+                "targetWeight": 12
+            }
+        ]
+    },
+    "expect": {
+        "result": "solved"
+    }
+}
\ No newline at end of file
diff --git a/dcapal-optimizer-wasm/tests/scenarios/allocation_tie.json b/dcapal-optimizer-wasm/tests/scenarios/allocation_tie.json
index 5e9d3ab3..10ba5f0c 100644
--- a/dcapal-optimizer-wasm/tests/scenarios/allocation_tie.json
+++ b/dcapal-optimizer-wasm/tests/scenarios/allocation_tie.json
@@ -107,9 +107,9 @@
         "result": "solved",
         "solution": {
             "VWCE.DE": {
-                "shares": 0,
-                "amount": 0,
-                "weight": 0
+                "shares": 162,
+                "amount": 16242.12,
+                "weight": 0.594165
             }
         }
     }
diff --git a/dcapal-optimizer-wasm/tests/scenarios/transactionFees/fixed-transaction-fee-asset-override.json b/dcapal-optimizer-wasm/tests/scenarios/transactionFees/fixed-transaction-fee-asset-override.json
new file mode 100644
index 00000000..56222510
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/scenarios/transactionFees/fixed-transaction-fee-asset-override.json
@@ -0,0 +1,135 @@
+{
+    "algorithm": "advanced",
+    "budget": 2300,
+    "isBuyOnly": true,
+    "portfolio": {
+        "quoteCcy": "eur",
+        "fees": {
+            "maxFeeImpact": 0.005,
+            "feeStructure": {
+                "type": "fixed",
+                "feeAmount": 2.95
+            }
+        },
+        "assets": [
+            {
+                "symbol": "VWCE.MI",
+                "name": "Vanguard FTSE All-World UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 101.49,
+                "qty": 162,
+                "amount": 16441.379999999997,
+                "weight": 59.11647752073499,
+                "targetWeight": 58
+            },
+            {
+                "symbol": "IWMO.MI",
+                "name": "iShares Edge MSCI World Momentum Factor UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.79,
+                "qty": 39,
+                "amount": 2019.81,
+                "weight": 7.26241060428965,
+                "targetWeight": 12
+            },
+            {
+                "symbol": "ZPRV.DU",
+                "name": "SPDR MSCI US.S.C.V.W.UETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.9,
+                "qty": 36,
+                "amount": 1868.3999999999999,
+                "weight": 6.718002174984172,
+                "targetWeight": 7.2
+            },
+            {
+                "symbol": "ZPRX.DU",
+                "name": "SPDR MSCI EUR.SM.CA.VALUE",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 44.625,
+                "qty": 27,
+                "amount": 1204.875,
+                "weight": 4.332237674258218,
+                "targetWeight": 4.8,
+                "fees": {
+                    "maxFeeImpact": 0.05,
+                    "feeStructure": {
+                        "type": "fixed",
+                        "feeAmount": 0.99
+                    }
+                }
+            },
+            {
+                "symbol": "AGGH.MI",
+                "name": "iShares Core Global Aggregate Bond UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 4.61,
+                "qty": 797,
+                "amount": 3674.17,
+                "weight": 13.210812487294795,
+                "targetWeight": 10
+            },
+            {
+                "symbol": "EPRA.PA",
+                "name": "Amundi Index Solutions - Amundi Index FTSE EPRA NAREIT Global",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 58.748,
+                "qty": 14,
+                "amount": 822.472,
+                "weight": 2.9572728991990913,
+                "targetWeight": 0
+            },
+            {
+                "symbol": "btc",
+                "name": "Bitcoin",
+                "aclass": "CRYPTO",
+                "baseCcy": "btc",
+                "provider": "DCAPal",
+                "price": 26898.1621,
+                "qty": 0.04474,
+                "amount": 1203.4237723540002,
+                "weight": 4.327019653233693,
+                "targetWeight": 5.6
+            },
+            {
+                "symbol": "eth",
+                "name": "Ethereum",
+                "aclass": "CRYPTO",
+                "baseCcy": "eth",
+                "provider": "DCAPal",
+                "price": 1700.2179,
+                "qty": 0.33955,
+                "amount": 577.308987945,
+                "weight": 2.0757669860053634,
+                "targetWeight": 2.4
+            }
+        ]
+    },
+    "expect": {
+        "result": "solved",
+        "solution": {
+            "ZPRV.DU": {
+                "shares": 36,
+                "amount": 1868.40,
+                "weight": 0.062049
+            },
+            "ZPRX.DU": {
+                "shares": 32,
+                "amount": 1428,
+                "weight": 0.047423
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/dcapal-optimizer-wasm/tests/scenarios/transactionFees/fixed-transaction-fee.json b/dcapal-optimizer-wasm/tests/scenarios/transactionFees/fixed-transaction-fee.json
new file mode 100644
index 00000000..341f9a33
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/scenarios/transactionFees/fixed-transaction-fee.json
@@ -0,0 +1,128 @@
+{
+    "algorithm": "advanced",
+    "budget": 2300,
+    "isBuyOnly": true,
+    "portfolio": {
+        "quoteCcy": "eur",
+        "fees": {
+            "maxFeeImpact": 0.005,
+            "feeStructure": {
+                "type": "fixed",
+                "feeAmount": 2.95
+            }
+        },
+        "assets": [
+            {
+                "symbol": "VWCE.MI",
+                "name": "Vanguard FTSE All-World UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 101.49,
+                "qty": 162,
+                "amount": 16441.379999999997,
+                "weight": 59.11647752073499,
+                "targetWeight": 58
+            },
+            {
+                "symbol": "IWMO.MI",
+                "name": "iShares Edge MSCI World Momentum Factor UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.79,
+                "qty": 39,
+                "amount": 2019.81,
+                "weight": 7.26241060428965,
+                "targetWeight": 12
+            },
+            {
+                "symbol": "ZPRV.DU",
+                "name": "SPDR MSCI US.S.C.V.W.UETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.9,
+                "qty": 36,
+                "amount": 1868.3999999999999,
+                "weight": 6.718002174984172,
+                "targetWeight": 7.2
+            },
+            {
+                "symbol": "ZPRX.DU",
+                "name": "SPDR MSCI EUR.SM.CA.VALUE",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 44.625,
+                "qty": 27,
+                "amount": 1204.875,
+                "weight": 4.332237674258218,
+                "targetWeight": 4.8
+            },
+            {
+                "symbol": "AGGH.MI",
+                "name": "iShares Core Global Aggregate Bond UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 4.61,
+                "qty": 797,
+                "amount": 3674.17,
+                "weight": 13.210812487294795,
+                "targetWeight": 10
+            },
+            {
+                "symbol": "EPRA.PA",
+                "name": "Amundi Index Solutions - Amundi Index FTSE EPRA NAREIT Global",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 58.748,
+                "qty": 14,
+                "amount": 822.472,
+                "weight": 2.9572728991990913,
+                "targetWeight": 0
+            },
+            {
+                "symbol": "btc",
+                "name": "Bitcoin",
+                "aclass": "CRYPTO",
+                "baseCcy": "btc",
+                "provider": "DCAPal",
+                "price": 26898.1621,
+                "qty": 0.04474,
+                "amount": 1203.4237723540002,
+                "weight": 4.327019653233693,
+                "targetWeight": 5.6
+            },
+            {
+                "symbol": "eth",
+                "name": "Ethereum",
+                "aclass": "CRYPTO",
+                "baseCcy": "eth",
+                "provider": "DCAPal",
+                "price": 1700.2179,
+                "qty": 0.33955,
+                "amount": 577.308987945,
+                "weight": 2.0757669860053634,
+                "targetWeight": 2.4
+            }
+        ]
+    },
+    "expect": {
+        "result": "solved",
+        "solution": {
+            "ZPRV.DU": {
+                "shares": 36,
+                "amount": 1868.40,
+                "weight": 0.062049
+            },
+            "ZPRX.DU": {
+                "shares": 27,
+                "amount": 1204.875,
+                "weight": 0.040013
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/dcapal-optimizer-wasm/tests/scenarios/transactionFees/variable-transaction-fee.json b/dcapal-optimizer-wasm/tests/scenarios/transactionFees/variable-transaction-fee.json
new file mode 100644
index 00000000..045c3679
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/scenarios/transactionFees/variable-transaction-fee.json
@@ -0,0 +1,130 @@
+{
+    "algorithm": "advanced",
+    "budget": 5000,
+    "isBuyOnly": true,
+    "portfolio": {
+        "quoteCcy": "eur",
+        "fees": {
+            "maxFeeImpact": 0.005,
+            "feeStructure": {
+                "type": "variable",
+                "feeRate": 0.0019,
+                "minFee": 1.5,
+                "maxFee": 18
+            }
+        },
+        "assets": [
+            {
+                "symbol": "VWCE.MI",
+                "name": "Vanguard FTSE All-World UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 101.49,
+                "qty": 162,
+                "amount": 16441.379999999997,
+                "weight": 59.11647752073499,
+                "targetWeight": 58
+            },
+            {
+                "symbol": "IWMO.MI",
+                "name": "iShares Edge MSCI World Momentum Factor UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.79,
+                "qty": 39,
+                "amount": 2019.81,
+                "weight": 7.26241060428965,
+                "targetWeight": 12
+            },
+            {
+                "symbol": "ZPRV.DU",
+                "name": "SPDR MSCI US.S.C.V.W.UETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 51.9,
+                "qty": 36,
+                "amount": 1868.3999999999999,
+                "weight": 6.718002174984172,
+                "targetWeight": 7.2
+            },
+            {
+                "symbol": "ZPRX.DU",
+                "name": "SPDR MSCI EUR.SM.CA.VALUE",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 44.625,
+                "qty": 27,
+                "amount": 1204.875,
+                "weight": 4.332237674258218,
+                "targetWeight": 4.8
+            },
+            {
+                "symbol": "AGGH.MI",
+                "name": "iShares Core Global Aggregate Bond UCITS ETF",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 4.61,
+                "qty": 797,
+                "amount": 3674.17,
+                "weight": 13.210812487294795,
+                "targetWeight": 10
+            },
+            {
+                "symbol": "EPRA.PA",
+                "name": "Amundi Index Solutions - Amundi Index FTSE EPRA NAREIT Global",
+                "aclass": "EQUITY",
+                "baseCcy": "eur",
+                "provider": "YF",
+                "price": 58.748,
+                "qty": 14,
+                "amount": 822.472,
+                "weight": 2.9572728991990913,
+                "targetWeight": 0
+            },
+            {
+                "symbol": "btc",
+                "name": "Bitcoin",
+                "aclass": "CRYPTO",
+                "baseCcy": "btc",
+                "provider": "DCAPal",
+                "price": 26898.1621,
+                "qty": 0.04474,
+                "amount": 1203.4237723540002,
+                "weight": 4.327019653233693,
+                "targetWeight": 5.6
+            },
+            {
+                "symbol": "eth",
+                "name": "Ethereum",
+                "aclass": "CRYPTO",
+                "baseCcy": "eth",
+                "provider": "DCAPal",
+                "price": 1700.2179,
+                "qty": 0.33955,
+                "amount": 577.308987945,
+                "weight": 2.0757669860053634,
+                "targetWeight": 2.4
+            }
+        ]
+    },
+    "expect": {
+        "result": "solved",
+        "solution": {
+            "ZPRV.DU": {
+                "shares": 45,
+                "amount": 2335.5,
+                "weight": 0.071179
+            },
+            "ZPRX.DU": {
+                "shares": 27,
+                "amount": 1204.875,
+                "weight": 0.036721
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/dcapal-optimizer-wasm/tests/test_runner.rs b/dcapal-optimizer-wasm/tests/test_runner.rs
deleted file mode 100644
index 7455ce6e..00000000
--- a/dcapal-optimizer-wasm/tests/test_runner.rs
+++ /dev/null
@@ -1,175 +0,0 @@
-use anyhow::anyhow;
-use dcapal_optimizer_wasm::{
-    optimize::advanced, JsAdvancedAsset, JsAdvancedOptions, JsProblemOptions,
-};
-
-use glob::glob;
-use log::info;
-use rust_decimal::prelude::*;
-use serde::{Deserialize, Serialize};
-use std::{collections::BTreeMap, fs::File, io::BufReader, path::Path};
-
-const SCENARIOS_PATH: &str = "./tests/scenarios";
-
-#[test_log::test]
-fn test_runner() -> anyhow::Result<()> {
-    info!("==> 🚀  DcaPal Test Runner - Engine ignited");
-
-    let path = Path::new(SCENARIOS_PATH).canonicalize()?;
-    let pattern = format!("{}/**/*.json", SCENARIOS_PATH);
-    info!("==> ⚙️  Loading test scenarios from \"{}\"", path.display());
-
-    let scenarios = glob(&pattern)?.filter_map(|e| e.ok()).collect::<Vec<_>>();
-    for path in &scenarios {
-        info!("==> ⚙️  Loading scenario {:?}", path.file_name().unwrap());
-
-        let scenario = read_scenario_from_file(path)?;
-
-        let options = JsProblemOptions::from(scenario);
-        let is_solved = match options {
-            JsProblemOptions::Advanced(o) => solve_advanced(o)?,
-            JsProblemOptions::Basic(_) => todo!(),
-        };
-
-        assert!(is_solved);
-
-        // TODO: Build Js objects to reuse parsing logic
-        // TODO: If expect == build error test Result
-        // TODO: Solve and test expectations
-    }
-
-    Ok(())
-}
-
-fn solve_advanced(options: JsAdvancedOptions) -> anyhow::Result<bool> {
-    let options = advanced::ProblemOptions::try_from(options).map_err(|e| anyhow!("{}", e))?;
-    let problem = advanced::Problem::new(options);
-    let solution = problem.solve();
-
-    Ok(solution.is_solved)
-}
-
-fn read_scenario_from_file(path: &Path) -> anyhow::Result<Scenario> {
-    let file = File::open(path)?;
-    let reader = BufReader::new(file);
-
-    let scenario: Scenario = serde_json::from_reader(reader)?;
-    Ok(scenario)
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Scenario {
-    pub algorithm: Algorithm,
-    #[serde(with = "rust_decimal::serde::float")]
-    pub budget: Decimal,
-    pub is_buy_only: bool,
-    pub portfolio: Portfolio,
-    pub expect: Expect,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub enum Algorithm {
-    Basic,
-    Advanced,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Portfolio {
-    pub quote_ccy: String,
-    pub assets: Vec<Asset>,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Asset {
-    pub symbol: String,
-    pub name: String,
-    pub aclass: AssetClass,
-    #[serde(with = "rust_decimal::serde::float")]
-    pub price: Decimal,
-    #[serde(with = "rust_decimal::serde::float")]
-    pub qty: Decimal,
-    #[serde(with = "rust_decimal::serde::float")]
-    pub target_weight: Decimal,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "UPPERCASE")]
-pub enum AssetClass {
-    Equity,
-    Crypto,
-    Currency,
-}
-
-impl AssetClass {
-    fn is_whole_shares(&self) -> bool {
-        matches!(self, AssetClass::Equity)
-    }
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(tag = "result", rename_all = "camelCase")]
-pub enum Expect {
-    BuildError,
-    Solved(Solution),
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct Solution {
-    pub solution: BTreeMap<String, AssetSolution>,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct AssetSolution {
-    #[serde(with = "rust_decimal::serde::float")]
-    pub shares: Decimal,
-    #[serde(with = "rust_decimal::serde::float")]
-    pub amount: Decimal,
-    #[serde(with = "rust_decimal::serde::float")]
-    pub weight: Decimal,
-}
-
-impl From<Scenario> for JsProblemOptions {
-    fn from(scenario: Scenario) -> Self {
-        match scenario.algorithm {
-            Algorithm::Basic => todo!(),
-            Algorithm::Advanced => Self::Advanced(from_advanced(scenario)),
-        }
-    }
-}
-
-fn from_advanced(scenario: Scenario) -> JsAdvancedOptions {
-    let budget = scenario.budget.to_f64().unwrap();
-    let pfolio_ccy = scenario.portfolio.quote_ccy;
-    let is_buy_only = scenario.is_buy_only;
-    let assets = scenario
-        .portfolio
-        .assets
-        .into_iter()
-        .map(|a| (a.symbol.clone(), a.into()))
-        .collect::<_>();
-
-    JsAdvancedOptions {
-        budget,
-        pfolio_ccy,
-        assets,
-        is_buy_only,
-    }
-}
-
-impl From<Asset> for JsAdvancedAsset {
-    fn from(value: Asset) -> Self {
-        JsAdvancedAsset {
-            symbol: value.symbol,
-            shares: value.qty.to_f64().unwrap(),
-            price: value.price.to_f64().unwrap(),
-            target_weight: value.target_weight.to_f64().unwrap() / 100.,
-            is_whole_shares: value.aclass.is_whole_shares(),
-        }
-    }
-}
diff --git a/dcapal-optimizer-wasm/tests/test_runner/error.rs b/dcapal-optimizer-wasm/tests/test_runner/error.rs
new file mode 100644
index 00000000..c434075f
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/test_runner/error.rs
@@ -0,0 +1,7 @@
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum Error {
+    #[error("Failed to build proble. Bad input: {0}")]
+    BadProblemInput(String),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
diff --git a/dcapal-optimizer-wasm/tests/test_runner/main.rs b/dcapal-optimizer-wasm/tests/test_runner/main.rs
new file mode 100644
index 00000000..ae2b98a8
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/test_runner/main.rs
@@ -0,0 +1,104 @@
+mod error;
+mod scenario;
+
+#[macro_use]
+mod utils_macros;
+
+use dcapal_optimizer_wasm::{
+    optimize::advanced::{self, Solution},
+    JsAdvancedOptions, JsProblemOptions,
+};
+
+use glob::glob;
+use log::info;
+use std::{fs::File, io::BufReader, path::Path};
+
+use error::{Error, Result};
+use scenario::{Expect, ExpectedSolution, Scenario};
+
+const SCENARIOS_PATH: &str = "./tests/scenarios";
+
+#[test_log::test]
+fn test_runner() -> anyhow::Result<()> {
+    info!("==> 🚀  DcaPal Test Runner - Engine ignited");
+
+    let path = Path::new(SCENARIOS_PATH).canonicalize()?;
+    let pattern = format!("{}/**/*.json", SCENARIOS_PATH);
+    info!("==> ⚙️  Loading test scenarios from \"{}\"", path.display());
+
+    let scenarios = glob(&pattern)?.filter_map(|e| e.ok()).collect::<Vec<_>>();
+    for path in &scenarios {
+        info!("==> 💿  Running scenario {:?}", path.file_name().unwrap());
+
+        let scenario = read_scenario_from_file(path)?;
+        let (options, expect) = scenario.split();
+
+        // Build problem from options and solve it
+        let res = match options {
+            JsProblemOptions::Advanced(o) => build_solve_advanced(o),
+            JsProblemOptions::Basic(_) => todo!(),
+        };
+
+        info!("==> 🔬  Checking expectations");
+
+        if matches!(expect, Expect::BuildError) {
+            assert!(res.is_err(), "expect={expect:?} res={res:?}");
+        } else {
+            assert!(res.is_ok(), "expect={expect:?} res={res:?}");
+        }
+
+        match (expect, res.unwrap()) {
+            (Expect::Solved(expected), TestSolution::Advanced(sol)) => {
+                check_expect_advanced(&expected, &sol)
+            }
+            (Expect::Solved(_), TestSolution::Basic) => todo!(),
+            (Expect::BuildError, _) => unreachable!(),
+        }
+    }
+
+    Ok(())
+}
+
+fn read_scenario_from_file(path: &Path) -> anyhow::Result<Scenario> {
+    let file = File::open(path)?;
+    let reader = BufReader::new(file);
+
+    let scenario: Scenario = serde_json::from_reader(reader)?;
+    Ok(scenario)
+}
+
+fn build_solve_advanced(options: JsAdvancedOptions) -> Result<TestSolution> {
+    let options = advanced::ProblemOptions::try_from(options).map_err(Error::BadProblemInput)?;
+
+    let problem = advanced::Problem::new(options);
+    let solution = problem.solve();
+
+    Ok(TestSolution::Advanced(solution))
+}
+
+fn check_expect_advanced(expected: &ExpectedSolution, sol: &Solution) {
+    assert!(sol.is_solved, "Expected solved problem: sol={sol:?}");
+
+    let Some(ref expected) = expected.solution else {
+        return;
+    };
+
+    for (id, exp) in expected {
+        let asset = sol.assets.get(id);
+        assert!(
+            asset.is_some(),
+            "Asset not found in solution: id={id} sol={sol:?}"
+        );
+        let asset = asset.unwrap();
+
+        asset_equal_field!(id, exp, asset, shares);
+        asset_equal_field!(id, exp, asset, amount);
+        asset_equal_field!(id, exp, asset, weight);
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum TestSolution {
+    Advanced(advanced::Solution),
+    Basic,
+}
diff --git a/dcapal-optimizer-wasm/tests/test_runner/scenario.rs b/dcapal-optimizer-wasm/tests/test_runner/scenario.rs
new file mode 100644
index 00000000..48f099e8
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/test_runner/scenario.rs
@@ -0,0 +1,129 @@
+use std::collections::BTreeMap;
+
+use dcapal_optimizer_wasm::{
+    JsAdvancedAsset, JsAdvancedOptions, JsProblemOptions, JsTransactionFees,
+};
+use rust_decimal::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Scenario {
+    pub algorithm: Algorithm,
+    #[serde(with = "rust_decimal::serde::float")]
+    pub budget: Decimal,
+    pub is_buy_only: bool,
+    pub portfolio: Portfolio,
+    pub expect: Expect,
+}
+
+impl Scenario {
+    pub fn split(self) -> (JsProblemOptions, Expect) {
+        let expect = self.expect;
+        let options = match self.algorithm {
+            Algorithm::Basic => todo!(),
+            Algorithm::Advanced => {
+                let budget = self.budget.to_f64().unwrap();
+                let pfolio_ccy = self.portfolio.quote_ccy;
+                let is_buy_only = self.is_buy_only;
+                let assets = self
+                    .portfolio
+                    .assets
+                    .into_iter()
+                    .map(|a| (a.symbol.clone(), a.into()))
+                    .collect::<_>();
+
+                JsProblemOptions::Advanced(JsAdvancedOptions {
+                    budget,
+                    pfolio_ccy,
+                    assets,
+                    is_buy_only,
+                    fees: self.portfolio.fees,
+                })
+            }
+        };
+
+        (options, expect)
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Algorithm {
+    Basic,
+    Advanced,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Portfolio {
+    pub quote_ccy: String,
+    pub assets: Vec<Asset>,
+    pub fees: Option<JsTransactionFees>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Asset {
+    pub symbol: String,
+    pub name: String,
+    pub aclass: AssetClass,
+    #[serde(with = "rust_decimal::serde::float")]
+    pub price: Decimal,
+    #[serde(with = "rust_decimal::serde::float")]
+    pub qty: Decimal,
+    #[serde(with = "rust_decimal::serde::float")]
+    pub target_weight: Decimal,
+    pub fees: Option<JsTransactionFees>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum AssetClass {
+    Equity,
+    Crypto,
+    Currency,
+}
+
+impl AssetClass {
+    fn is_whole_shares(&self) -> bool {
+        matches!(self, AssetClass::Equity)
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(tag = "result", rename_all = "camelCase")]
+pub enum Expect {
+    BuildError,
+    Solved(ExpectedSolution),
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ExpectedSolution {
+    pub solution: Option<BTreeMap<String, AssetSolution>>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AssetSolution {
+    #[serde(with = "rust_decimal::serde::float_option")]
+    pub shares: Option<Decimal>,
+    #[serde(with = "rust_decimal::serde::float_option")]
+    pub amount: Option<Decimal>,
+    #[serde(with = "rust_decimal::serde::float_option")]
+    pub weight: Option<Decimal>,
+}
+
+impl From<Asset> for JsAdvancedAsset {
+    fn from(value: Asset) -> Self {
+        JsAdvancedAsset {
+            symbol: value.symbol,
+            shares: value.qty.to_f64().unwrap(),
+            price: value.price.to_f64().unwrap(),
+            target_weight: value.target_weight.to_f64().unwrap() / 100.,
+            is_whole_shares: value.aclass.is_whole_shares(),
+            fees: value.fees,
+        }
+    }
+}
diff --git a/dcapal-optimizer-wasm/tests/test_runner/utils_macros.rs b/dcapal-optimizer-wasm/tests/test_runner/utils_macros.rs
new file mode 100644
index 00000000..2b6a52fd
--- /dev/null
+++ b/dcapal-optimizer-wasm/tests/test_runner/utils_macros.rs
@@ -0,0 +1,15 @@
+macro_rules! asset_equal_field {
+    ($id:expr, $expected:expr, $actual:expr, $field:ident) => {
+        if let Some($field) = $expected.$field {
+            assert_eq!(
+                $field,
+                $actual.$field,
+                "{} mismatch on `{}`: expected={} actual={}",
+                $id,
+                stringify!($field),
+                $field,
+                $actual.$field
+            );
+        }
+    };
+}