diff --git a/src/CryptoTracker.Client/Pages/AssetFlow.razor b/src/CryptoTracker.Client/Pages/AssetFlow.razor
new file mode 100644
index 0000000..e194b99
--- /dev/null
+++ b/src/CryptoTracker.Client/Pages/AssetFlow.razor
@@ -0,0 +1,75 @@
+@page "/assetflow"
+@using CryptoTracker.Shared
+@inject IBalanceApi BalanceApi
+@inject IAssetFlowApi AssetFlowApi
+
+Asset Flow
+
+
Asset Flow
+
+@if (IsLoading)
+{
+
+}
+else
+{
+
+
+
+
+ @if (SelectedWallet != null)
+ {
+
+
+
+
+ }
+ @if (SelectedAsset != null)
+ {
+
+
+ }
+}
+
+@if (FlowLines != null && FlowLines.Count > 0)
+{
+
+
+
+
+
Aktueller Wert: @CurrentValue.ToString("0.00") EUR
+
Gewinn/Verlust: @(CurrentValue - TotalCost).ToString("0.00") EUR
+
+}
+
+@if (ErrorMessage != null)
+{
+ @ErrorMessage
+}
diff --git a/src/CryptoTracker.Client/Pages/AssetFlow.razor.cs b/src/CryptoTracker.Client/Pages/AssetFlow.razor.cs
new file mode 100644
index 0000000..2b15c23
--- /dev/null
+++ b/src/CryptoTracker.Client/Pages/AssetFlow.razor.cs
@@ -0,0 +1,140 @@
+using CryptoTracker.Shared;
+using Microsoft.AspNetCore.Components;
+
+namespace CryptoTracker.Client.Pages;
+
+public partial class AssetFlow
+{
+ private bool IsLoading { get; set; } = true;
+ private string? ErrorMessage { get; set; }
+ private IList Balances { get; set; } = new List();
+ private IList WalletNames { get; set; } = new List();
+ private IList WalletAssetOptions { get; set; } = new List();
+ private string? SelectedWallet { get; set; }
+ private string? SelectedAsset { get; set; }
+ private decimal SelectedAmount { get; set; }
+ private IList? FlowLines { get; set; }
+ private decimal CurrentValue { get; set; }
+ private decimal TotalCost { get; set; }
+
+ private record AssetOption(string Symbol, string Display, decimal Amount);
+
+ private const int WalletSpacing = 150;
+ private const int RowSpacing = 60;
+
+ private Dictionary WalletPositions
+ => FlowLines?
+ .SelectMany(l => new[] { l.SourceWallet, l.TargetWallet })
+ .Where(w => !string.IsNullOrEmpty(w))
+ .Distinct()
+ .Select((w, i) => new { w, i })
+ .ToDictionary(x => x.w!, x => (x.i + 1) * WalletSpacing)
+ ?? new Dictionary();
+
+ private int SvgWidth => WalletPositions.Count * WalletSpacing + WalletSpacing;
+ private int SvgHeight => (FlowLines?.Count ?? 0) * RowSpacing + 60;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync();
+ await LoadBalances();
+ IsLoading = false;
+ }
+
+ private async Task LoadBalances()
+ {
+ Balances = await BalanceApi.GetBalancesAsync();
+ WalletNames = Balances.Select(b => b.Platform).ToList();
+ }
+
+ private async Task LoadFlows()
+ {
+ if (string.IsNullOrEmpty(SelectedAsset) || string.IsNullOrEmpty(SelectedWallet))
+ return;
+
+ try
+ {
+ FlowLines = await AssetFlowApi.GetAssetFlowsAsync(SelectedWallet, SelectedAsset);
+ await LoadCurrentValue();
+ CalculateCost();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ private void CalculateCost()
+ {
+ if (FlowLines == null)
+ return;
+
+ decimal cost = 0;
+ var fifo = new Queue<(decimal qty, decimal price)>();
+ foreach (var line in FlowLines.OrderBy(l => l.DateTime))
+ {
+ if (line.TradeId.HasValue && line.OppositeSymbol == "EUR" && line.Amount > 0)
+ {
+ fifo.Enqueue((line.Amount, line.Price ?? 0));
+ cost += line.Amount * (line.Price ?? 0);
+ }
+ else if (line.TradeId.HasValue && line.OppositeSymbol == "EUR" && line.Amount < 0)
+ {
+ var remaining = Math.Abs(line.Amount);
+ while (remaining > 0 && fifo.Count > 0)
+ {
+ var item = fifo.Peek();
+ var take = Math.Min(remaining, item.qty);
+ cost -= take * item.price;
+ item.qty -= take;
+ remaining -= take;
+ if (item.qty <= 0)
+ fifo.Dequeue();
+ else
+ {
+ fifo.Dequeue();
+ fifo.Enqueue(item);
+ }
+ }
+ }
+ }
+
+ TotalCost = cost;
+ }
+
+ private void OnWalletChanged(string wallet)
+ {
+ SelectedWallet = wallet;
+ var assets = Balances.FirstOrDefault(b => b.Platform == wallet)?.Assets ?? new List();
+ WalletAssetOptions = assets.Where(a => a.Amount > 0)
+ .Select(a => new AssetOption(a.Symbol, $"{a.Symbol} ({a.Amount})", a.Amount))
+ .ToList();
+ SelectedAsset = null;
+ SelectedAmount = 0;
+ }
+
+ private void OnAssetChanged(string symbol)
+ {
+ SelectedAsset = symbol;
+ var opt = WalletAssetOptions.FirstOrDefault(o => o.Symbol == symbol);
+ SelectedAmount = opt?.Amount ?? 0;
+ }
+
+ private void SetMax()
+ {
+ var opt = WalletAssetOptions.FirstOrDefault(o => o.Symbol == SelectedAsset);
+ if (opt != null)
+ SelectedAmount = opt.Amount;
+ }
+
+ private async Task LoadCurrentValue()
+ {
+ var balances = await BalanceApi.GetBalancesAsync();
+ var asset = balances.FirstOrDefault(b => b.Platform == SelectedWallet)?.Assets.FirstOrDefault(a => a.Symbol == SelectedAsset);
+ if (asset != null && asset.Amount > 0)
+ {
+ var rate = asset.EuroValue / asset.Amount;
+ CurrentValue = SelectedAmount * rate;
+ }
+ }
+}
diff --git a/src/CryptoTracker.Client/Program.cs b/src/CryptoTracker.Client/Program.cs
index a80cbea..71c44a7 100644
--- a/src/CryptoTracker.Client/Program.cs
+++ b/src/CryptoTracker.Client/Program.cs
@@ -9,6 +9,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/CryptoTracker.Client/RestClients/AssetFlowRestClient.cs b/src/CryptoTracker.Client/RestClients/AssetFlowRestClient.cs
new file mode 100644
index 0000000..fa030df
--- /dev/null
+++ b/src/CryptoTracker.Client/RestClients/AssetFlowRestClient.cs
@@ -0,0 +1,18 @@
+using CryptoTracker.Shared;
+using System.Net.Http.Json;
+
+namespace CryptoTracker.Client.RestClients;
+
+public class AssetFlowRestClient : IAssetFlowApi
+{
+ private readonly HttpClient _http;
+
+ public AssetFlowRestClient(HttpClient http)
+ {
+ _http = http;
+ }
+
+ public async Task> GetAssetFlowsAsync(string walletName, string symbol)
+ => await _http.GetFromJsonAsync>($"api/AssetFlow/GetAssetFlows?walletName={walletName}&symbol={symbol}")
+ ?? new List();
+}
diff --git a/src/CryptoTracker.Client/Shared/Api/IAssetFlowApi.cs b/src/CryptoTracker.Client/Shared/Api/IAssetFlowApi.cs
new file mode 100644
index 0000000..d9a9cea
--- /dev/null
+++ b/src/CryptoTracker.Client/Shared/Api/IAssetFlowApi.cs
@@ -0,0 +1,6 @@
+namespace CryptoTracker.Shared;
+
+public interface IAssetFlowApi
+{
+ Task> GetAssetFlowsAsync(string walletName, string symbol);
+}
diff --git a/src/CryptoTracker.Client/Shared/AssetFlowLineDTO.cs b/src/CryptoTracker.Client/Shared/AssetFlowLineDTO.cs
new file mode 100644
index 0000000..399bb85
--- /dev/null
+++ b/src/CryptoTracker.Client/Shared/AssetFlowLineDTO.cs
@@ -0,0 +1,13 @@
+namespace CryptoTracker.Shared;
+
+public record AssetFlowLineDTO(
+ DateTimeOffset DateTime,
+ string? TransactionId,
+ int? TradeId,
+ string? SourceWallet,
+ string? TargetWallet,
+ string Symbol,
+ decimal Amount,
+ string? OppositeSymbol,
+ decimal? OppositeAmount,
+ decimal? Price);
diff --git a/src/CryptoTracker/Components/Layout/NavMenu.razor b/src/CryptoTracker/Components/Layout/NavMenu.razor
index d86c0a3..b2c0aef 100644
--- a/src/CryptoTracker/Components/Layout/NavMenu.razor
+++ b/src/CryptoTracker/Components/Layout/NavMenu.razor
@@ -3,6 +3,7 @@
+
diff --git a/src/CryptoTracker/Controllers/AssetFlowController.cs b/src/CryptoTracker/Controllers/AssetFlowController.cs
new file mode 100644
index 0000000..ae42052
--- /dev/null
+++ b/src/CryptoTracker/Controllers/AssetFlowController.cs
@@ -0,0 +1,24 @@
+using CryptoTracker.Services;
+using CryptoTracker.Shared;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CryptoTracker.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AssetFlowController : ControllerBase, IAssetFlowApi
+{
+ private readonly AssetFlowService _service;
+
+ public AssetFlowController(AssetFlowService service)
+ {
+ _service = service;
+ }
+
+ [HttpGet("GetAssetFlows")]
+ public async Task> GetAssetFlows(string walletName, string symbol)
+ => await _service.GetAssetFlows(walletName, symbol);
+
+ Task> IAssetFlowApi.GetAssetFlowsAsync(string walletName, string symbol)
+ => _service.GetAssetFlows(walletName, symbol);
+}
diff --git a/src/CryptoTracker/Services/AssetFlowService.cs b/src/CryptoTracker/Services/AssetFlowService.cs
new file mode 100644
index 0000000..182002d
--- /dev/null
+++ b/src/CryptoTracker/Services/AssetFlowService.cs
@@ -0,0 +1,83 @@
+using CryptoTracker.Entities;
+using CryptoTracker.Shared;
+using Microsoft.EntityFrameworkCore;
+
+namespace CryptoTracker.Services;
+
+public class AssetFlowService
+{
+ private readonly CryptoTrackerDbContext _dbContext;
+
+ public AssetFlowService(CryptoTrackerDbContext dbContext)
+ {
+ _dbContext = dbContext;
+ }
+
+ public async Task> GetAssetFlows(string walletName, string symbol)
+ {
+ var trades = await _dbContext.CryptoTrades
+ .Include(t => t.Wallet)
+ .Where(t => (t.Symbol == symbol || t.OppositeSymbol == symbol) && t.Wallet.Name == walletName)
+ .ToListAsync();
+
+ var transactions = await _dbContext.CryptoTransactions
+ .Include(t => t.Wallet)
+ .Include(t => t.OppositeWallet)
+ .Where(t => t.Symbol == symbol && (t.Wallet.Name == walletName || t.OppositeWallet!.Name == walletName))
+ .ToListAsync();
+
+ var result = new List();
+
+ foreach (var t in transactions)
+ {
+ var amount = t.TransactionType == TransactionType.Receive ? t.QuantityAfterFee : -t.Quantity;
+ result.Add(new AssetFlowLineDTO(
+ t.DateTime,
+ t.TransactionId,
+ null,
+ t.TransactionType == TransactionType.Send ? t.Wallet.Name : t.OppositeWallet?.Name,
+ t.TransactionType == TransactionType.Receive ? t.Wallet.Name : t.OppositeWallet?.Name,
+ t.Symbol,
+ amount,
+ null,
+ null,
+ null));
+ }
+
+ foreach (var tr in trades)
+ {
+ if (tr.Symbol == symbol)
+ {
+ var amount = tr.TradeType == TradeType.Buy ? tr.QuantityAfterFee : -tr.Quantity;
+ result.Add(new AssetFlowLineDTO(
+ tr.DateTime,
+ null,
+ tr.Id,
+ tr.Wallet.Name,
+ tr.Wallet.Name,
+ tr.Symbol,
+ amount,
+ tr.OppositeSymbol,
+ tr.TradeType == TradeType.Buy ? -tr.Quantity * tr.Price : tr.QuantityAfterFee * tr.Price,
+ tr.Price));
+ }
+ else if (tr.OppositeSymbol == symbol)
+ {
+ var amount = tr.TradeType == TradeType.Buy ? -(tr.Quantity * tr.Price) : tr.QuantityAfterFee * tr.Price;
+ result.Add(new AssetFlowLineDTO(
+ tr.DateTime,
+ null,
+ tr.Id,
+ tr.Wallet.Name,
+ tr.Wallet.Name,
+ tr.OppositeSymbol,
+ amount,
+ tr.Symbol,
+ tr.TradeType == TradeType.Buy ? tr.QuantityAfterFee : -tr.Quantity,
+ tr.Price));
+ }
+ }
+
+ return result.OrderBy(r => r.DateTime).ToList();
+ }
+}
diff --git a/src/CryptoTracker/Startup.cs b/src/CryptoTracker/Startup.cs
index 35003be..1e89bbf 100644
--- a/src/CryptoTracker/Startup.cs
+++ b/src/CryptoTracker/Startup.cs
@@ -52,11 +52,13 @@ public void ConfigureServices(IServiceCollection services)
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();