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) +{ +
+ + + + + + + @foreach (var wp in WalletPositions) + { + @((MarkupString)$"{wp.Key}") + } + @for (int i = 0; i < FlowLines.Count; i++) + { + var line = FlowLines[i]; + var y = 40 + i * RowSpacing; + var x1 = WalletPositions[line.SourceWallet ?? string.Empty]; + var x2 = WalletPositions[line.TargetWallet ?? string.Empty]; + + @((MarkupString)$"{line.DateTime:g} {line.Symbol} {line.Amount}") + } + +
+
+

Aktueller Wert: @CurrentValue.ToString("0.00") EUR

+

Gewinn/Verlust: @(CurrentValue - TotalCost).ToString("0.00") EUR

+
+} + +@if (ErrorMessage != null) +{ + +} 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();