Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/src/Host.Api/Jobs/DailySnapshotJob.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Investments.Application.Manual;
using Investments.Application.NetWorth;
using Investments.Application.Portfolio;

Expand All @@ -16,6 +17,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
await using var scope = scopes.CreateAsyncScope();
// Coins entered by hand have no sync: fetch their price before valuing the day.
await scope.ServiceProvider.GetRequiredService<ManualHoldingService>()
.RefreshPricesAsync(ManualHoldingService.PriceMaxAge, stoppingToken);
await scope.ServiceProvider.GetRequiredService<PortfolioSnapshotter>().SnapshotTodayAsync(stoppingToken);
await scope.ServiceProvider.GetRequiredService<NetWorthService>().SnapshotAsync(stoppingToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public async Task<byte[]> ExportAsync(CancellationToken ct)
snapshots = await investments.PortfolioSnapshots.AsNoTracking().ToListAsync(ct),
targetAllocation = await investments.TargetAllocations.AsNoTracking().ToListAsync(ct),
manualAssets = await investments.ManualAssets.AsNoTracking().Include(a => a.Valuations).ToListAsync(ct),
manualHoldings = await investments.ManualHoldings.AsNoTracking().Include(h => h.Rewards).ToListAsync(ct),
netWorth = await investments.NetWorthSnapshots.AsNoTracking().ToListAsync(ct),
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public interface IInvestmentsDb
DbSet<TargetAllocation> TargetAllocations { get; }
DbSet<ManualAsset> ManualAssets { get; }
DbSet<NetWorthSnapshot> NetWorthSnapshots { get; }
DbSet<ManualHolding> ManualHoldings { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ public sealed record ListingMatch(string Symbol, string Currency);

public readonly record struct DailyClose(DateOnly Date, decimal Close);

/// <summary>A coin offered by the provider's search, e.g. Id "SOL" (Yahoo "SOL-USD"), shown as "SOL · Solana".</summary>
/// <param name="Id">Provider-neutral coin id used to find its listings ("SOL", "PEPE24478").</param>
/// <param name="Symbol">Ticker to show ("PEPE").</param>
public sealed record CoinMatch(string Id, string Symbol, string Name);

/// <summary>Daily closes in <see cref="Currency"/> (minor units such as GBp already converted to GBP).</summary>
public sealed record PriceSeries(string Currency, IReadOnlyList<DailyClose> Closes);

Expand All @@ -33,6 +38,13 @@ public interface IPriceHistorySource

/// <summary>Closes for [from, to] (inclusive); null or empty when the provider has no data for that range.</summary>
Task<PriceSeries?> GetDailyClosesAsync(string symbol, DateOnly from, DateOnly to, CancellationToken ct);

/// <summary>
/// Coins whose name or ticker matches <paramref name="query"/>, best first. Only the typed text is sent.
/// Providers without crypto return nothing.
/// </summary>
Task<IReadOnlyList<CoinMatch>> SearchCoinsAsync(string query, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<CoinMatch>>([]);
}

/// <summary>Used when <c>MarketData:Provider</c> is <c>none</c>: no request is ever made.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Investments.Application.Calculations;

/// <summary>Pure rules for coins entered by hand (valued with public prices, quoted every day of the week).</summary>
public static class ManualCrypto
{
/// <summary>A close older than this is not used to value a reward.</summary>
public const int StaleCloseDays = 7;

/// <summary>
/// EUR income of a reward: the quantity at the latest close on or before the day it was received (within a
/// week), else at <paramref name="fallbackPrice"/> (the latest known price).
/// </summary>
public static decimal RewardValue(decimal quantity, DateOnly receivedOn,
IEnumerable<(DateOnly Date, decimal Close)> closes, decimal fallbackPrice)
{
var close = closes
.Where(c => c.Date <= receivedOn && c.Date >= receivedOn.AddDays(-StaleCloseDays) && c.Close > 0)
.OrderByDescending(c => c.Date)
.Select(c => (decimal?)c.Close)
.FirstOrDefault();
return decimal.Round(quantity * (close ?? fallbackPrice), 4);
}

/// <summary>
/// When a close was observed: today's daily bar is the live price (as of now); an older bar is that day's final
/// close (end of the UTC day — crypto days are UTC days).
/// </summary>
public static DateTimeOffset PriceAsOf(DateOnly closeDate, DateTimeOffset now) =>
closeDate >= DateOnly.FromDateTime(now.UtcDateTime)
? now
: new DateTimeOffset(closeDate.ToDateTime(new TimeOnly(23, 59, 59)), TimeSpan.Zero);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Finance.Application.Http;
using Investments.Application.Abstractions;
using Investments.Application.Fx;
using Investments.Application.Manual;
using Investments.Application.NetWorth;
using Investments.Application.Portfolio;
using Investments.Domain;
Expand Down Expand Up @@ -38,7 +39,7 @@ public ManualAssetValidator()

/// <summary>
/// Investment data comes from read-only broker integrations: every endpoint here is GET, except user
/// preferences (target allocation, asset-class overrides) and manually valued assets.
/// preferences (target allocation, asset-class overrides), manually valued assets and coins entered by hand.
/// </summary>
public static class InvestmentEndpoints
{
Expand Down Expand Up @@ -163,6 +164,7 @@ await db.TargetAllocations.AsNoTracking()
: Results.Ok(new { currency, date = on, eurPerUnit = factor });
}).WithTags("FX");

app.MapManualHoldings();
return app;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentValidation;
using Investments.Application.Fx;
using Investments.Application.Manual;
using Investments.Application.NetWorth;
using Investments.Application.Portfolio;
using Investments.Application.Prices;
Expand All @@ -21,6 +22,7 @@ public static IServiceCollection AddInvestmentsApplication(this IServiceCollecti
services.AddScoped<HistoryReconstructor>();
services.AddSingleton<HistoryRebuildQueue>();
services.AddScoped<NetWorthService>();
services.AddScoped<ManualHoldingService>();
return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using FluentValidation;
using Finance.Application.Http;
using Investments.Domain;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace Investments.Application.Manual;

public sealed class CreateManualHoldingValidator : AbstractValidator<CreateManualHoldingRequest>
{
public CreateManualHoldingValidator(TimeProvider clock)
{
RuleFor(x => x.CoinId).NotEmpty().WithMessage("Pick a coin from the list.")
.Matches("^[A-Za-z0-9]{1,30}$").WithMessage("Pick a coin from the list.");
RuleFor(x => x.Symbol).NotEmpty().MaximumLength(40);
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
ManualHoldingRules.Apply(this, x => x.Quantity, x => x.AveragePrice, x => x.Location, x => x.Notes,
x => x.HeldSince, clock);
}
}

public sealed class UpdateManualHoldingValidator : AbstractValidator<UpdateManualHoldingRequest>
{
public UpdateManualHoldingValidator(TimeProvider clock) =>
ManualHoldingRules.Apply(this, x => x.Quantity, x => x.AveragePrice, x => x.Location, x => x.Notes,
x => x.HeldSince, clock);
}

public sealed class RewardValidator : AbstractValidator<RewardRequest>
{
public RewardValidator(TimeProvider clock)
{
RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Enter how many coins you received.")
.LessThan(1_000_000_000_000m).WithMessage("That quantity looks too large.");
RuleFor(x => x.Kind).IsInEnum();
RuleFor(x => x.ReceivedOn)
.Must(d => d is null || d <= DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime).AddDays(1))
.WithMessage("The date cannot be in the future.");
RuleFor(x => x.Note).MaximumLength(ManualHolding.MaxNotes);
}
}

internal static class ManualHoldingRules
{
public static void Apply<T>(AbstractValidator<T> v, System.Linq.Expressions.Expression<Func<T, decimal>> quantity,
System.Linq.Expressions.Expression<Func<T, decimal>> price,
System.Linq.Expressions.Expression<Func<T, string?>> location,
System.Linq.Expressions.Expression<Func<T, string?>> notes,
System.Linq.Expressions.Expression<Func<T, DateOnly?>> heldSince, TimeProvider clock)
{
v.RuleFor(quantity).GreaterThan(0).WithMessage("Enter how many coins you hold (more than 0).")
.LessThan(1_000_000_000_000m).WithMessage("That quantity looks too large.");
v.RuleFor(price).GreaterThanOrEqualTo(0).WithMessage("The average buy price cannot be negative.")
.LessThan(100_000_000m).WithMessage("That price looks too large.");
v.RuleFor(location).MaximumLength(80).WithMessage("Keep the location under 80 characters.");
v.RuleFor(notes).MaximumLength(ManualHolding.MaxNotes)
.WithMessage($"Keep notes under {ManualHolding.MaxNotes} characters.");
v.RuleFor(heldSince)
.Must(d => d is null || d <= DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime).AddDays(1))
.WithMessage("The date cannot be in the future.")
.Must(d => d is null || d >= new DateOnly(2009, 1, 3))
.WithMessage("The date is before Bitcoin existed.");
}
}

/// <summary>Coins entered by hand: the only investment records the user writes (no exchange or wallet access).</summary>
public static class ManualHoldingEndpoints
{
public static IEndpointRouteBuilder MapManualHoldings(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/portfolio/manual").WithTags("Portfolio");

group.MapGet("/coins", (string? q, ManualHoldingService s, CancellationToken ct) => s.SearchAsync(q, ct));
group.MapGet("/", (ManualHoldingService s, CancellationToken ct) => s.ListAsync(ct));

group.MapPost("/", async (CreateManualHoldingRequest req, ManualHoldingService s, CancellationToken ct) =>
(await s.CreateAsync(req, ct)).ToHttp(id => Results.Created($"/api/portfolio/manual/{id}", new { id })))
.Validate<CreateManualHoldingRequest>();

group.MapPut("/{id:guid}", async (Guid id, UpdateManualHoldingRequest req, ManualHoldingService s,
CancellationToken ct) => (await s.UpdateAsync(id, req, ct)).ToHttp())
.Validate<UpdateManualHoldingRequest>();

group.MapDelete("/{id:guid}", async (Guid id, ManualHoldingService s, CancellationToken ct) =>
(await s.DeleteAsync(id, ct)).ToHttp());

group.MapPost("/{id:guid}/rewards", async (Guid id, RewardRequest req, ManualHoldingService s,
CancellationToken ct) =>
(await s.AddRewardAsync(id, req, ct)).ToHttp(rewardId =>
Results.Created($"/api/portfolio/manual/{id}/rewards/{rewardId}", new { id = rewardId })))
.Validate<RewardRequest>();

group.MapDelete("/{id:guid}/rewards/{rewardId:guid}", async (Guid id, Guid rewardId, ManualHoldingService s,
CancellationToken ct) => (await s.RemoveRewardAsync(id, rewardId, ct)).ToHttp());

// Called when the portfolio is opened: prices older than 15 minutes are fetched again.
group.MapPost("/refresh-prices", async (ManualHoldingService s, CancellationToken ct) =>
Results.Ok(new { updated = await s.RefreshPricesAsync(ManualHoldingService.PriceMaxAge, ct) }));

return app;
}
}
Loading
Loading