Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/CryptoTracker.Client/Pages/AssetFlow.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
@page "/assetflow"
@using CryptoTracker.Shared
@inject IBalanceApi BalanceApi
@inject IAssetFlowApi AssetFlowApi

<PageTitle>Asset Flow</PageTitle>

<h1>Asset Flow</h1>

@if (IsLoading)
{
<div class="d-flex justify-content-center my-5">
<div class="spinner-border" role="status"></div>
</div>
}
else
{
<div class="mb-3" style="max-width:200px">
<label>Wallet</label>
<RadzenDropDown Style="width:100%" Data="@WalletNames" @bind-Value="SelectedWallet" Change="@(args => OnWalletChanged(args?.ToString() ?? string.Empty))" Placeholder="Select Wallet" />
</div>
@if (SelectedWallet != null)
{
<div class="mb-3" style="max-width:200px">
<label>Asset</label>
<RadzenDropDown Style="width:100%" Data="@WalletAssetOptions" TextProperty="Display" ValueProperty="Symbol" @bind-Value="SelectedAsset" Change="@(args => OnAssetChanged(args?.ToString() ?? string.Empty))" Placeholder="Select Asset" />
</div>
}
@if (SelectedAsset != null)
{
<div class="mb-3" style="max-width:200px">
<label>Menge</label>
<div class="d-flex">
<RadzenNumeric style="width:100%" TValue="decimal" @bind-Value="SelectedAmount" />
<RadzenButton class="ms-2" Text="Max" Click="SetMax" />
</div>
</div>
<RadzenButton Text="Load Flows" Click="LoadFlows" Disabled="SelectedAmount <= 0" />
}
}

@if (FlowLines != null && FlowLines.Count > 0)
{
<div class="mt-4" style="overflow-x:auto">
<svg width="@SvgWidth" height="@SvgHeight">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="black" />
</marker>
</defs>
@foreach (var wp in WalletPositions)
{
@((MarkupString)$"<text x='{wp.Value}' y='20' text-anchor='middle'>{wp.Key}</text>")
}
@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];
<line x1="@x1" y1="@y" x2="@x2" y2="@y" stroke="black" marker-end="url(#arrow)" />
@((MarkupString)$"<text x='{(x1 + x2) / 2}' y='{y - 5}' text-anchor='middle' style='font-size:smaller'>{line.DateTime:g} {line.Symbol} {line.Amount}</text>")
}
</svg>
</div>
<div class="mt-3">
<p>Aktueller Wert: @CurrentValue.ToString("0.00") EUR</p>
<p>Gewinn/Verlust: @(CurrentValue - TotalCost).ToString("0.00") EUR</p>
</div>
}

@if (ErrorMessage != null)
{
<div class="alert alert-danger" role="alert">@ErrorMessage</div>
}
140 changes: 140 additions & 0 deletions src/CryptoTracker.Client/Pages/AssetFlow.razor.cs
Original file line number Diff line number Diff line change
@@ -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<PlatformBalanceDTO> Balances { get; set; } = new List<PlatformBalanceDTO>();
private IList<string> WalletNames { get; set; } = new List<string>();
private IList<AssetOption> WalletAssetOptions { get; set; } = new List<AssetOption>();
private string? SelectedWallet { get; set; }
private string? SelectedAsset { get; set; }
private decimal SelectedAmount { get; set; }
private IList<AssetFlowLineDTO>? 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<string, int> 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<string, int>();

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<AssetBalanceDTO>();
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;
}
}
}
1 change: 1 addition & 0 deletions src/CryptoTracker.Client/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

builder.Services.AddScoped<IWalletApi, WalletRestClient>();
builder.Services.AddScoped<IFlowApi, FlowRestClient>();
builder.Services.AddScoped<IAssetFlowApi, AssetFlowRestClient>();
builder.Services.AddScoped<IBalanceApi, BalanceRestClient>();
builder.Services.AddScoped<IDataImportApi, DataImportRestClient>();
builder.Services.AddScoped<IImportEntriesApi, ImportEntriesRestClient>();
Expand Down
18 changes: 18 additions & 0 deletions src/CryptoTracker.Client/RestClients/AssetFlowRestClient.cs
Original file line number Diff line number Diff line change
@@ -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<IList<AssetFlowLineDTO>> GetAssetFlowsAsync(string walletName, string symbol)
=> await _http.GetFromJsonAsync<IList<AssetFlowLineDTO>>($"api/AssetFlow/GetAssetFlows?walletName={walletName}&symbol={symbol}")
?? new List<AssetFlowLineDTO>();
}
6 changes: 6 additions & 0 deletions src/CryptoTracker.Client/Shared/Api/IAssetFlowApi.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CryptoTracker.Shared;

public interface IAssetFlowApi
{
Task<IList<AssetFlowLineDTO>> GetAssetFlowsAsync(string walletName, string symbol);
}
13 changes: 13 additions & 0 deletions src/CryptoTracker.Client/Shared/AssetFlowLineDTO.cs
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions src/CryptoTracker/Components/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<RadzenPanelMenu>
<RadzenPanelMenuItem Text="Überblick" Path="/" Icon="dashboard" />
<RadzenPanelMenuItem Text="Bilanzen" Path="bilanzen" Icon="pie_chart" />
<RadzenPanelMenuItem Text="Asset Flow" Path="assetflow" Icon="timeline" />
<RadzenPanelMenuItem Text="Wallets" Path="wallets" Icon="account_balance_wallet" />
<RadzenPanelMenuItem Text="Import" Path="import" Icon="upload">
<RadzenPanelMenuItem Text="Binance Deposit" Path="import/binance-deposit" />
Expand Down
24 changes: 24 additions & 0 deletions src/CryptoTracker/Controllers/AssetFlowController.cs
Original file line number Diff line number Diff line change
@@ -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<IList<AssetFlowLineDTO>> GetAssetFlows(string walletName, string symbol)
=> await _service.GetAssetFlows(walletName, symbol);

Task<IList<AssetFlowLineDTO>> IAssetFlowApi.GetAssetFlowsAsync(string walletName, string symbol)
=> _service.GetAssetFlows(walletName, symbol);
}
83 changes: 83 additions & 0 deletions src/CryptoTracker/Services/AssetFlowService.cs
Original file line number Diff line number Diff line change
@@ -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<IList<AssetFlowLineDTO>> 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<AssetFlowLineDTO>();

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();
}
}
2 changes: 2 additions & 0 deletions src/CryptoTracker/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ public void ConfigureServices(IServiceCollection services)
services.AddScoped<DataImportService>();
services.AddScoped<WalletService>();
services.AddScoped<FlowService>();
services.AddScoped<AssetFlowService>();
services.AddScoped<IFinanceValueProvider, FinanceValueProvider>();
services.AddScoped<BalanceService>();

services.AddScoped<IWalletApi, WalletController>();
services.AddScoped<IFlowApi, FlowController>();
services.AddScoped<IAssetFlowApi, AssetFlowController>();
services.AddScoped<IBalanceApi, BalanceController>();
services.AddScoped<IDataImportApi, DataImportController>();
services.AddScoped<IImportEntriesApi, ImportEntriesController>();
Expand Down
Loading