diff --git a/README.md b/README.md index 0e008e3..2cefd7d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ monthly checklist — and rebuilds it on a database. | **Recurring** | Salary, rent, subscriptions propose *expected* transactions; nothing is booked until you confirm, edit or skip. | | **Goals** | Emergency fund, trips, house deposit — progress from linked savings, monthly amount needed. | | **Excel migration** | Analyze → map → validate → preview → import → undo. Reconciles every month against the workbook's own totals to the cent before anything is written. Idempotent re-imports. | -| **Investments** | Read-only sync from Trading 212 (Public API + CSV) and Interactive Brokers (Flex Web Service). Consolidated portfolio by ISIN with per-broker holdings, allocation vs target, dividends with withholding tax, TWR and XIRR, net worth history. [Setup](docs/broker-integrations.md). | +| **Investments** | Read-only sync from Trading 212 (Public API + CSV), Interactive Brokers (Flex Web Service) and Binance (spot + Simple Earn, with Earn rewards as income; the key is checked to be read-only). Consolidated portfolio by ISIN with per-broker holdings, allocation vs target, dividends with withholding tax, TWR and XIRR, net worth history. [Setup](docs/broker-integrations.md). | | **Export** | Real .xlsx (Excel tables, native charts, schema-validated), CSV per dataset (Excel-PT dialect), complete JSON archive with restore. | | **AI** | MCP server for Claude Code / ChatGPT / local models and an optional in-app assistant, both behind a policy gateway: per-client revocable tokens, explicit scopes, question-specific minimal answers, untrusted-text wrapping, audit. Read-only by default; opt-in, audited write tools with a 30-day recycle bin. [Details](docs/ai.md). | | **Security** | Single owner, mandatory TOTP MFA, CSRF protection, rate limiting, encrypted identifiers, least-privilege database roles, VPN-only exposure, encrypted backups. | @@ -203,6 +203,7 @@ cd web && npm test # frontend unit tests - [x] Phase 7–8 — Read-only MCP server behind an AI policy gateway - [x] Phase 9 — In-app assistant through the same gateway - [x] Phase 10 — Opt-in AI write tools with a 30-day recycle bin ([ADR-0008](docs/adr/0008-ai-write-access.md)) +- [x] Phase 11 — Binance spot + Simple Earn through a key verified as read-only ([ADR-0009](docs/adr/0009-binance-read-only-key.md)) ## Tech diff --git a/backend/src/Modules/Integrations/Integrations.Application/Contracts/IInvestmentProvider.cs b/backend/src/Modules/Integrations/Integrations.Application/Contracts/IInvestmentProvider.cs index d7b2589..947f674 100644 --- a/backend/src/Modules/Integrations/Integrations.Application/Contracts/IInvestmentProvider.cs +++ b/backend/src/Modules/Integrations/Integrations.Application/Contracts/IInvestmentProvider.cs @@ -23,6 +23,7 @@ public enum BrokerKind { Trading212 = 0, InteractiveBrokers = 1, + Binance = 2, Demo = 99, } diff --git a/backend/src/Modules/Integrations/Integrations.Application/IntegrationEndpoints.cs b/backend/src/Modules/Integrations/Integrations.Application/IntegrationEndpoints.cs index 6c8edff..95713bf 100644 --- a/backend/src/Modules/Integrations/Integrations.Application/IntegrationEndpoints.cs +++ b/backend/src/Modules/Integrations/Integrations.Application/IntegrationEndpoints.cs @@ -59,6 +59,11 @@ public static class IntegrationEndpoints new("token", "Flex Web Service token", true, true, null), new("queryId", "Activity Flex Query ID", false, true, null), ], "Client Portal → Performance & Reports → Flex Queries. Create the Activity query described in docs/broker-integrations.md, enable Flex Web Service, restrict the token to your IP and set its expiry."), + new(BrokerKind.Binance, "Binance", + [ + new("apiKey", "API key", false, true, null), + new("apiSecret", "Secret key", true, true, "Shown once when the key is created."), + ], "Binance → Account → API Management → Create API (system generated). Leave only \"Enable Reading\" ticked — a key that can trade, transfer or withdraw is refused — and restrict access to your home IP."), new(BrokerKind.Demo, "Demo broker (fictitious data)", [ new("profile", "Profile", false, false, "a or b — both hold the same ETF to show consolidation; c has no valuation history, like Trading 212"), diff --git a/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncPipeline.cs b/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncPipeline.cs index 3c5c0d5..b259a0a 100644 --- a/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncPipeline.cs +++ b/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncPipeline.cs @@ -111,6 +111,7 @@ public async Task RunAsync(Guid connectionId, SyncTrigger trigger, C { BrokerKind.Trading212 => DataSource.Trading212, BrokerKind.InteractiveBrokers => DataSource.InteractiveBrokers, + BrokerKind.Binance => DataSource.Binance, _ => DataSource.Demo, }; diff --git a/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncSchedule.cs b/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncSchedule.cs index ec21577..c3cbc05 100644 --- a/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncSchedule.cs +++ b/backend/src/Modules/Integrations/Integrations.Application/Sync/SyncSchedule.cs @@ -30,7 +30,7 @@ public static bool IsDue(BrokerKind kind, DateTimeOffset? lastSuccess, LastAttem var success = lastSuccess ?? DateTimeOffset.MinValue; return kind switch { - BrokerKind.Trading212 => now - success > TimeSpan.FromHours(4), + BrokerKind.Trading212 or BrokerKind.Binance => now - success > TimeSpan.FromHours(4), // Flex data is end-of-day: one run per day, after 06:00 UTC. BrokerKind.InteractiveBrokers => now.Hour >= 6 && success.UtcDateTime.Date < now.UtcDateTime.Date, _ => now - success > TimeSpan.FromHours(24), diff --git a/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceClient.cs b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceClient.cs new file mode 100644 index 0000000..fd52676 --- /dev/null +++ b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceClient.cs @@ -0,0 +1,239 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Integrations.Application.Contracts; +using SharedKernel.Http; + +namespace Integrations.Infrastructure.Binance; + +/// +/// Minimal Binance Spot API client: signed GET requests to account, Simple Earn and fill history, plus public prices. +/// Nothing else can be sent: the HttpClient is wrapped in an allow-list of these GET paths (no order, convert, +/// transfer, subscription or withdrawal endpoint), and refuses any key that +/// Binance reports as able to trade or move funds. +/// +internal sealed class BinanceClient(HttpClient http, RateGate gate, TimeProvider clock) +{ + public const string Host = "api.binance.com"; + + internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) + { + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + /// Everything the integration may call; any other request is blocked before it leaves the process. + public static IEnumerable AllowList() => + [ + AllowedRequest.Get(Host, "/sapi/v1/account/apiRestrictions"), + AllowedRequest.Get(Host, "/api/v3/account"), + AllowedRequest.Get(Host, "/api/v3/myTrades"), + AllowedRequest.Get(Host, "/sapi/v1/simple-earn/(flexible|locked)/position"), + AllowedRequest.Get(Host, "/sapi/v1/simple-earn/(flexible|locked)/history/rewardsRecord"), + AllowedRequest.Get(Host, "/api/v3/ticker/price"), + AllowedRequest.Get(Host, "/api/v3/klines"), + ]; + + /// Request weight is generous (6,000/min); spacing keeps a sync far below it. + private static readonly TimeSpan Spacing = TimeSpan.FromMilliseconds(250); + + private string? _apiKey; + private byte[]? _secret; + + public BinanceClient Configure(string apiKey, string apiSecret) + { + _apiKey = apiKey.Trim(); + _secret = Encoding.UTF8.GetBytes(apiSecret.Trim()); + return this; + } + + /// + /// The key must be read-only: Binance reports what it may do, and any trading, margin, futures, options, transfer + /// or withdrawal permission is refused before anything is read. + /// + public async Task EnsureReadOnlyKeyAsync(CancellationToken ct) + { + var r = await SignedAsync("/sapi/v1/account/apiRestrictions", "", ct); + var extra = new List(); + if (r.EnableSpotAndMarginTrading) extra.Add("spot & margin trading"); + if (r.EnableMargin) extra.Add("margin"); + if (r.EnableFutures) extra.Add("futures"); + if (r.EnableVanillaOptions) extra.Add("options"); + if (r.EnablePortfolioMarginTrading) extra.Add("portfolio margin"); + if (r.EnableFixApiTrade) extra.Add("FIX trading"); + if (r.EnableWithdrawals) extra.Add("withdrawals"); + if (r.EnableInternalTransfer || r.PermitsUniversalTransfer) extra.Add("transfers"); + if (extra.Count > 0) + { + throw new ProviderConfigurationException( + $"This Binance API key can do more than read ({string.Join(", ", extra)}). Create a key with only \"Enable Reading\" and update the connection."); + } + + if (!r.EnableReading) + { + throw new ProviderConfigurationException("This Binance API key cannot read the account. Enable \"Enable Reading\"."); + } + } + + public Task AccountAsync(CancellationToken ct) => + SignedAsync("/api/v3/account", "omitZeroBalances=true", ct); + + public Task> FlexiblePositionsAsync(CancellationToken ct) => + PagedAsync("/sapi/v1/simple-earn/flexible/position", "", ct); + + public Task> LockedPositionsAsync(CancellationToken ct) => + PagedAsync("/sapi/v1/simple-earn/locked/position", "", ct); + + /// Flexible rewards of one kind (REALTIME, BONUS or REWARDS) between two instants. + public Task> FlexibleRewardsAsync(string type, DateTimeOffset from, DateTimeOffset to, + CancellationToken ct) => + PagedAsync("/sapi/v1/simple-earn/flexible/history/rewardsRecord", + $"type={type}&startTime={from.ToUnixTimeMilliseconds()}&endTime={to.ToUnixTimeMilliseconds()}", ct); + + public Task> LockedRewardsAsync(DateTimeOffset from, DateTimeOffset to, + CancellationToken ct) => + PagedAsync("/sapi/v1/simple-earn/locked/history/rewardsRecord", + $"startTime={from.ToUnixTimeMilliseconds()}&endTime={to.ToUnixTimeMilliseconds()}", ct); + + /// Every fill of the account on , oldest first (paged by fill id). + public async Task> FillsAsync(string symbol, CancellationToken ct) + { + var fills = new List(); + long fromId = 0; + for (var page = 0; page < 200; page++) + { + var batch = await SignedAsync>("/api/v3/myTrades", + $"symbol={symbol}&fromId={fromId}&limit=1000", ct); + fills.AddRange(batch); + if (batch.Count < 1000) + { + break; + } + + fromId = batch[^1].Id + 1; + } + + return fills; + } + + /// Latest price of every symbol (public). + public async Task> PricesAsync(CancellationToken ct) => + (await GetAsync>("/api/v3/ticker/price", ct)) + .GroupBy(t => t.Symbol).ToDictionary(g => g.Key, g => g.First().Price, StringComparer.Ordinal); + + /// Daily closes of from (UTC days, public). + public async Task> DailyClosesAsync(string symbol, DateOnly from, CancellationToken ct) + { + var closes = new Dictionary(); + var start = new DateTimeOffset(from.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + var today = DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime); + for (var page = 0; page < 20 && DateOnly.FromDateTime(start.UtcDateTime) <= today; page++) + { + var rows = await GetAsync>( + $"/api/v3/klines?symbol={symbol}&interval=1d&startTime={start.ToUnixTimeMilliseconds()}&limit=1000", ct); + foreach (var row in rows) + { + var day = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(row[0].GetInt64()).UtcDateTime); + closes[day] = decimal.Parse(row[4].GetString()!, CultureInfo.InvariantCulture); + } + + if (rows.Count < 1000) + { + break; + } + + start = DateTimeOffset.FromUnixTimeMilliseconds(rows[^1][0].GetInt64()).AddDays(1); + } + + return closes; + } + + private async Task> PagedAsync(string path, string query, CancellationToken ct) + { + var rows = new List(); + for (var current = 1; current <= 50; current++) + { + var q = (query.Length > 0 ? query + "&" : "") + $"current={current}&size=100"; + var page = await SignedAsync>(path, q, ct); + rows.AddRange(page.Rows ?? []); + if (page.Rows is null || page.Rows.Count < 100 || rows.Count >= page.Total) + { + break; + } + } + + return rows; + } + + private Task SignedAsync(string path, string query, CancellationToken ct) + { + if (_apiKey is null || _secret is null) + { + throw new InvalidOperationException("Client not configured."); + } + + var payload = (query.Length > 0 ? query + "&" : "") + + $"recvWindow=10000×tamp={clock.GetUtcNow().ToUnixTimeMilliseconds()}"; + var signature = Convert.ToHexStringLower(HMACSHA256.HashData(_secret, Encoding.UTF8.GetBytes(payload))); + return SendAsync($"{path}?{payload}&signature={signature}", _apiKey, ct); + } + + private Task GetAsync(string pathAndQuery, CancellationToken ct) => SendAsync(pathAndQuery, null, ct); + + private async Task SendAsync(string pathAndQuery, string? apiKey, CancellationToken ct) + { + await gate.WaitAsync($"binance:{Host}", Spacing, ct); + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri($"https://{Host}{pathAndQuery}")); + if (apiKey is not null) + { + request.Headers.Add("X-MBX-APIKEY", apiKey); + } + + using var response = await http.SendAsync(request, ct); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(Json, ct) + ?? throw new ProviderUnavailableException("Empty response from Binance."); + } + + BinanceError? error = null; + try + { + error = await response.Content.ReadFromJsonAsync(Json, ct); + } + catch (JsonException) + { + // Not a Binance error body (e.g. a proxy page): the status code decides. + } + + if (response.StatusCode is HttpStatusCode.TooManyRequests or (HttpStatusCode)418) + { + gate.BlockUntil($"binance:{Host}", clock.GetUtcNow() + RetryAfter(response)); + throw new ProviderUnavailableException("Binance rate limit reached; will retry on the next sync."); + } + + // -2014/-2015: bad key, IP not allowed or permission missing; -1022: wrong secret; 401: same. + if (response.StatusCode == HttpStatusCode.Unauthorized || error?.Code is -2014 or -2015 or -1022) + { + throw new ProviderConfigurationException( + "Binance rejected the API key (invalid key or secret, IP not allowed, or reading not enabled). Check the key and update the connection."); + } + + // -1021: this server's clock is too far from Binance's. + if (error?.Code == -1021) + { + throw new ProviderUnavailableException("Binance rejected the request time; check this server's clock (NTP)."); + } + + throw new ProviderUnavailableException( + $"Binance returned {(int)response.StatusCode}{(error is null ? "" : $" ({error.Code}: {error.Msg})")}."); + } + + private static TimeSpan RetryAfter(HttpResponseMessage response) => + response.Headers.RetryAfter?.Delta is { } delta && delta < TimeSpan.FromMinutes(5) + ? delta + : TimeSpan.FromMinutes(1); +} diff --git a/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceModels.cs b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceModels.cs new file mode 100644 index 0000000..7177d74 --- /dev/null +++ b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceModels.cs @@ -0,0 +1,40 @@ +namespace Integrations.Infrastructure.Binance; + +// Binance sends decimals as strings; the client reads them with AllowReadingFromString. + +internal sealed record BinanceApiRestrictions( + bool EnableReading, + bool EnableSpotAndMarginTrading, + bool EnableWithdrawals, + bool EnableInternalTransfer, + bool EnableMargin, + bool EnableFutures, + bool PermitsUniversalTransfer, + bool EnableVanillaOptions, + bool EnablePortfolioMarginTrading, + bool EnableFixApiTrade, + bool EnableFixReadOnly, + bool IpRestrict); + +internal sealed record BinanceBalance(string Asset, decimal Free, decimal Locked); + +internal sealed record BinanceAccount(List Balances); + +internal sealed record BinancePage(List? Rows, int Total); + +internal sealed record BinanceFlexiblePosition(string Asset, decimal TotalAmount); + +internal sealed record BinanceLockedPosition(string Asset, decimal Amount); + +/// A flexible-product reward: rewards of asset at time (ms). +internal sealed record BinanceFlexibleReward(string Asset, decimal Rewards, string? Type, long Time); + +/// A locked-product reward: amount of asset at time (ms). +internal sealed record BinanceLockedReward(string Asset, decimal Amount, string? Type, long Time, long? PositionId); + +internal sealed record BinanceTicker(string Symbol, decimal Price); + +/// An executed fill of the account on one symbol (GET /api/v3/myTrades). +internal sealed record BinanceFill(long Id, decimal Price, decimal Qty, decimal QuoteQty, long Time, bool IsBuyer); + +internal sealed record BinanceError(int Code, string? Msg); diff --git a/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceProvider.cs b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceProvider.cs new file mode 100644 index 0000000..9b4afc4 --- /dev/null +++ b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceProvider.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using Integrations.Application.Contracts; +using Investments.Application.Sync; +using Investments.Domain; + +namespace Integrations.Infrastructure.Binance; + +/// +/// Binance (Spot + Simple Earn), read-only. Each coin is one position: spot balance plus flexible and locked Earn. +/// Earn rewards are income, like dividends, valued in EUR at the day's close. Positions and prices are in EUR; the +/// cost of a coin comes from its fills on EUR and dollar-stablecoin pairs (unknown for coins deposited, converted or +/// earned: those are valued from the first sync on, with no gain). +/// +internal sealed class BinanceProvider(BinanceClient client, TimeProvider clock) : IInvestmentProvider +{ + /// How far back the first sync reads Earn rewards. + public const int FirstSyncDays = 180; + + private const int WindowDays = 30; + private static readonly string[] FlexibleRewardTypes = ["REALTIME", "BONUS", "REWARDS"]; + + private sealed record Holdings(Dictionary Spot, Dictionary Earn, + Dictionary Prices); + + private Holdings? _holdings; + + public BrokerKind Kind => BrokerKind.Binance; + + public async Task GetAccountSnapshotAsync(CancellationToken ct) + { + var h = await HoldingsAsync(ct); + var cash = h.Spot.GetValueOrDefault("EUR") + h.Earn.GetValueOrDefault("EUR"); + var coins = Coins(h).Sum(c => c.Quantity * c.EurPrice); + // Binance exposes no valuation history here; the snapshotter records it daily from now on. + return new AccountSnapshot("EUR", cash, decimal.Round(cash + coins, 2), []); + } + + public async Task> GetPositionsAsync(CancellationToken ct) + { + var h = await HoldingsAsync(ct); + var now = clock.GetUtcNow(); + var coins = Coins(h).ToList(); + if (coins.Count == 0) + { + return []; + } + + var usdPerEur = h.Prices.GetValueOrDefault("EURUSDT"); + var usdCloses = usdPerEur > 0 && coins.Any(c => HasDollarPair(c.Asset, h.Prices)) + ? await client.DailyClosesAsync("EURUSDT", DateOnly.FromDateTime(now.UtcDateTime).AddYears(-3), ct) + : []; + var positions = new List(); + foreach (var c in coins) + { + var fills = new List<(string, BinanceFill)>(); + foreach (var quote in BinanceValuation.CostQuotes.Where(q => h.Prices.ContainsKey(c.Asset + q))) + { + fills.AddRange((await client.FillsAsync(c.Asset + quote, ct)).Select(f => (quote, f))); + } + + var average = BinanceValuation.AverageCost(fills, usdCloses, usdPerEur) ?? c.EurPrice; + positions.Add(new PositionReport(Security(c.Asset), c.Quantity, average, c.EurPrice, now)); + } + + return positions; + } + + /// Fills only set the cost of each position; they are not reported as trades (dollar pairs). + public Task> GetTransactionsAsync(DateTimeOffset? since, CancellationToken ct) => + Task.FromResult>([]); + + public async Task> GetDividendsAsync(DateTimeOffset? since, CancellationToken ct) + { + var h = await HoldingsAsync(ct); + var now = clock.GetUtcNow(); + var from = since ?? now.AddDays(-FirstSyncDays); + var rewards = new List<(string Kind, string Asset, decimal Amount, long Time)>(); + for (var start = from; start < now; start = start.AddDays(WindowDays)) + { + var end = start.AddDays(WindowDays) < now ? start.AddDays(WindowDays) : now; + foreach (var type in FlexibleRewardTypes) + { + rewards.AddRange((await client.FlexibleRewardsAsync(type, start, end, ct)) + .Select(r => ("flexible-" + type.ToLowerInvariant(), r.Asset, r.Rewards, r.Time))); + } + + rewards.AddRange((await client.LockedRewardsAsync(start, end, ct)) + .Select(r => ("locked", r.Asset, r.Amount, r.Time))); + } + + var dividends = new List(); + foreach (var group in rewards.Where(r => r.Amount > 0).GroupBy(r => r.Asset)) + { + var asset = group.Key; + if (BinanceValuation.EurPrice(asset, h.Prices) is not { } price) + { + continue; // not quoted on Binance in EUR or dollars: cannot be valued + } + + var first = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(group.Min(r => r.Time)).UtcDateTime); + var closes = await EurClosesAsync(asset, first.AddDays(-7), h.Prices, ct); + foreach (var r in group) + { + var day = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(r.Time).UtcDateTime); + var value = BinanceValuation.RewardValue(r.Amount, day, closes, price); + var id = $"binance:earn:{r.Kind}:{asset}:{r.Time}:{r.Amount.ToString(CultureInfo.InvariantCulture)}"; + dividends.Add(new DividendReport(id, Security(asset), day, null, null, value, "EUR", null, r.Amount)); + } + } + + return dividends; + } + + private async Task HoldingsAsync(CancellationToken ct) + { + if (_holdings is not null) + { + return _holdings; + } + + await client.EnsureReadOnlyKeyAsync(ct); + var earn = new Dictionary(StringComparer.Ordinal); + foreach (var p in await client.FlexiblePositionsAsync(ct)) + { + earn[p.Asset] = earn.GetValueOrDefault(p.Asset) + p.TotalAmount; + } + + foreach (var p in await client.LockedPositionsAsync(ct)) + { + earn[p.Asset] = earn.GetValueOrDefault(p.Asset) + p.Amount; + } + + var earnAssets = earn.Keys.ToHashSet(StringComparer.Ordinal); + var spot = (await client.AccountAsync(ct)).Balances + .Where(b => b.Free + b.Locked > 0 && !BinanceValuation.IsEarnShadow(b.Asset, earnAssets)) + .GroupBy(b => b.Asset) + .ToDictionary(g => g.Key, g => g.Sum(b => b.Free + b.Locked), StringComparer.Ordinal); + return _holdings = new Holdings(spot, earn, await client.PricesAsync(ct)); + } + + /// Every coin held (spot + Earn) that Binance can price in EUR; EUR itself is cash. + private static IEnumerable<(string Asset, decimal Quantity, decimal EurPrice)> Coins(Holdings h) => + h.Spot.Keys.Union(h.Earn.Keys) + .Where(a => a != "EUR") + .Select(a => (Asset: a, Quantity: h.Spot.GetValueOrDefault(a) + h.Earn.GetValueOrDefault(a), + Price: BinanceValuation.EurPrice(a, h.Prices))) + .Where(c => c.Quantity > 0 && c.Price is not null) + .Select(c => (c.Asset, c.Quantity, c.Price!.Value)) + .OrderBy(c => c.Asset, StringComparer.Ordinal); + + private static bool HasDollarPair(string asset, Dictionary prices) => + BinanceValuation.CostQuotes.Skip(1).Any(q => prices.ContainsKey(asset + q)); + + /// Daily EUR closes: the EUR pair, else a dollar pair divided by EURUSDT on the same day. + private async Task> EurClosesAsync(string asset, DateOnly from, + Dictionary prices, CancellationToken ct) + { + if (prices.ContainsKey(asset + "EUR")) + { + return await client.DailyClosesAsync(asset + "EUR", from, ct); + } + + var usdPerEur = await client.DailyClosesAsync("EURUSDT", from, ct); + if (asset is "USDT" or "USDC" or "FDUSD") + { + return usdPerEur.Where(x => x.Value > 0).ToDictionary(x => x.Key, x => decimal.Round(1m / x.Value, 8)); + } + + var pair = BinanceValuation.CostQuotes.Skip(1).Select(q => asset + q).FirstOrDefault(prices.ContainsKey); + if (pair is null) + { + return []; + } + + var usd = await client.DailyClosesAsync(pair, from, ct); + return usd.Where(x => usdPerEur.GetValueOrDefault(x.Key) > 0) + .ToDictionary(x => x.Key, x => decimal.Round(x.Value / usdPerEur[x.Key], 8)); + } + + private static SecurityReport Security(string asset) => + new(asset, null, asset, null, asset, "EUR", AssetClass.Crypto); +} diff --git a/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceValuation.cs b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceValuation.cs new file mode 100644 index 0000000..342b262 --- /dev/null +++ b/backend/src/Modules/Integrations/Integrations.Infrastructure/Binance/BinanceValuation.cs @@ -0,0 +1,99 @@ +namespace Integrations.Infrastructure.Binance; + +/// Pure rules for turning Binance balances, fills and rewards into EUR figures. +internal static class BinanceValuation +{ + /// Quotes whose fills count towards a coin's cost: EUR and the dollar stablecoins (≈ USD). + public static readonly string[] CostQuotes = ["EUR", "USDT", "USDC", "FDUSD"]; + + private static readonly HashSet UsdStable = new(StringComparer.Ordinal) { "USDT", "USDC", "FDUSD" }; + + /// + /// Flexible Earn used to appear in spot balances as "LD" + asset; those rows are the Earn position reported by + /// the Simple Earn endpoints, so they are dropped to avoid counting it twice. + /// + public static bool IsEarnShadow(string asset, IReadOnlySet earnAssets) => + asset.Length > 2 && asset.StartsWith("LD", StringComparison.Ordinal) && earnAssets.Contains(asset[2..]); + + /// + /// Price of one in EUR from the price list: the EUR pair, else a dollar stablecoin pair + /// converted with EURUSDT (dollars per euro). Null when Binance quotes neither. + /// + public static decimal? EurPrice(string asset, IReadOnlyDictionary prices) + { + if (asset == "EUR") + { + return 1m; + } + + if (prices.TryGetValue(asset + "EUR", out var eur) && eur > 0) + { + return eur; + } + + if (!prices.TryGetValue("EURUSDT", out var usdPerEur) || usdPerEur <= 0) + { + return null; + } + + if (UsdStable.Contains(asset)) + { + return Round(1m / usdPerEur); + } + + foreach (var quote in UsdStable) + { + if (prices.TryGetValue(asset + quote, out var usd) && usd > 0) + { + return Round(usd / usdPerEur); + } + } + + return null; + } + + /// + /// Average EUR cost of the coins bought, from fills in EUR or dollar stablecoins (moving average: sells reduce the + /// quantity, not the average). Dollar fills are converted with that day's EURUSDT close (else the latest one). + /// Null when nothing was bought on these pairs — coins deposited, converted or earned have no known cost. + /// + public static decimal? AverageCost(IEnumerable<(string Quote, BinanceFill Fill)> fills, + IReadOnlyDictionary usdPerEurCloses, decimal latestUsdPerEur) + { + decimal quantity = 0, cost = 0; + foreach (var (quote, f) in fills.OrderBy(x => x.Fill.Time)) + { + var day = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeMilliseconds(f.Time).UtcDateTime); + var toEur = quote == "EUR" ? 1m : 1m / UsdPerEurOn(day, usdPerEurCloses, latestUsdPerEur); + if (f.IsBuyer) + { + quantity += f.Qty; + cost += f.QuoteQty * toEur; + } + else if (quantity > 0) + { + var sold = Math.Min(f.Qty, quantity); + cost -= cost / quantity * sold; + quantity -= sold; + } + } + + return quantity > 0 ? Round(cost / quantity) : null; + } + + /// EUR value of a reward: quantity × that day's close in EUR (else the current price). + public static decimal RewardValue(decimal quantity, DateOnly day, IReadOnlyDictionary eurCloses, + decimal currentEurPrice) + { + var close = eurCloses.TryGetValue(day, out var c) && c > 0 + ? c + : eurCloses.Where(x => x.Key <= day && x.Key >= day.AddDays(-7) && x.Value > 0) + .OrderByDescending(x => x.Key).Select(x => (decimal?)x.Value).FirstOrDefault() ?? currentEurPrice; + return decimal.Round(quantity * close, 4); + } + + private static decimal UsdPerEurOn(DateOnly day, IReadOnlyDictionary closes, decimal latest) => + closes.TryGetValue(day, out var v) && v > 0 ? v : latest; + + private static decimal Round(decimal value) => decimal.Round(value, 8); +} diff --git a/backend/src/Modules/Integrations/Integrations.Infrastructure/IntegrationsInfrastructure.cs b/backend/src/Modules/Integrations/Integrations.Infrastructure/IntegrationsInfrastructure.cs index b636805..0fb2ad3 100644 --- a/backend/src/Modules/Integrations/Integrations.Infrastructure/IntegrationsInfrastructure.cs +++ b/backend/src/Modules/Integrations/Integrations.Infrastructure/IntegrationsInfrastructure.cs @@ -3,6 +3,7 @@ using Integrations.Application.Contracts; using Integrations.Application.Instruments; using Integrations.Application.Sync; +using Integrations.Infrastructure.Binance; using Integrations.Infrastructure.Demo; using Integrations.Infrastructure.Ibkr; using Integrations.Infrastructure.Persistence; @@ -30,8 +31,8 @@ internal sealed class InvestmentProviderFactory(IServiceProvider services, IConf : IInvestmentProviderFactory { public IReadOnlySet Available { get; } = config.GetValue("Integrations:EnableDemo") - ? new HashSet { BrokerKind.Trading212, BrokerKind.InteractiveBrokers, BrokerKind.Demo } - : new HashSet { BrokerKind.Trading212, BrokerKind.InteractiveBrokers }; + ? new HashSet { BrokerKind.Trading212, BrokerKind.InteractiveBrokers, BrokerKind.Binance, BrokerKind.Demo } + : new HashSet { BrokerKind.Trading212, BrokerKind.InteractiveBrokers, BrokerKind.Binance }; public IInvestmentProvider Create(BrokerKind kind, IReadOnlyDictionary credentials) { @@ -50,6 +51,8 @@ string Required(string key) => credentials.TryGetValue(key, out var v) && !strin .Configure(Required("apiKey"), Required("apiSecret"), credentials.GetValueOrDefault("environment") == "demo")), BrokerKind.InteractiveBrokers => new IbkrFlexProvider(services.GetRequiredService(), Required("token"), Required("queryId"), config.GetValue("Integrations:Ibkr:BackfillYears", 5), clock), + BrokerKind.Binance => new BinanceProvider(services.GetRequiredService() + .Configure(Required("apiKey"), Required("apiSecret")), clock), _ => new DemoProvider(credentials.GetValueOrDefault("profile") switch { "b" => "b", "c" => "c", _ => "a" }, clock), }; @@ -90,6 +93,12 @@ public static IServiceCollection AddIntegrationsInfrastructure(this IServiceColl c.DefaultRequestHeaders.UserAgent.ParseAdd("personal-finance/1.0"); }) .AddHttpMessageHandler(() => new AllowListHttpHandler(FlexClient.AllowList())); + services.AddHttpClient(c => + { + c.Timeout = TimeSpan.FromSeconds(60); + c.DefaultRequestHeaders.UserAgent.ParseAdd("personal-finance/1.0"); + }) + .AddHttpMessageHandler(() => new AllowListHttpHandler(BinanceClient.AllowList())); return services; } } diff --git a/backend/src/Modules/Investments/Investments.Application/Portfolio/PortfolioQueries.cs b/backend/src/Modules/Investments/Investments.Application/Portfolio/PortfolioQueries.cs index 8d5f87e..a74d828 100644 --- a/backend/src/Modules/Investments/Investments.Application/Portfolio/PortfolioQueries.cs +++ b/backend/src/Modules/Investments/Investments.Application/Portfolio/PortfolioQueries.cs @@ -489,7 +489,9 @@ DataSource SourceOf(Guid id, string? institution) => ?? cashSources.FirstOrDefault(s => s.AccountId == id)?.Source ?? (institution?.Contains("Interactive", StringComparison.OrdinalIgnoreCase) == true ? DataSource.InteractiveBrokers - : DataSource.Trading212); + : institution?.Contains("Binance", StringComparison.OrdinalIgnoreCase) == true + ? DataSource.Binance + : DataSource.Trading212); // An archived account with nothing left in it (a purged connection, an emptied crypto location) is gone. return accounts diff --git a/backend/src/Modules/Investments/Investments.Application/Prices/ListingHints.cs b/backend/src/Modules/Investments/Investments.Application/Prices/ListingHints.cs index 1ede4c4..91a34c4 100644 --- a/backend/src/Modules/Investments/Investments.Application/Prices/ListingHints.cs +++ b/backend/src/Modules/Investments/Investments.Application/Prices/ListingHints.cs @@ -59,7 +59,8 @@ public static partial class ListingHints { hints.Add(hint); } - else if (source == DataSource.Manual && security.EffectiveAssetClass == AssetClass.Crypto) + else if (source is DataSource.Manual or DataSource.Binance && + security.EffectiveAssetClass == AssetClass.Crypto) { hints.Add((symbol.ToUpperInvariant(), Crypto)); } diff --git a/backend/src/Modules/Investments/Investments.Application/Sync/PortfolioSyncWriter.cs b/backend/src/Modules/Investments/Investments.Application/Sync/PortfolioSyncWriter.cs index 83bff61..9ca24a9 100644 --- a/backend/src/Modules/Investments/Investments.Application/Sync/PortfolioSyncWriter.cs +++ b/backend/src/Modules/Investments/Investments.Application/Sync/PortfolioSyncWriter.cs @@ -25,9 +25,13 @@ public async Task ResolveSecurityAsync(SecurityReport report, DataSource s // Same ISIN at another broker → same security; that is what makes the consolidated view work. var isin = report.Isin?.Trim().ToUpperInvariant(); - var security = isin is null - ? null - : await db.Securities.FirstOrDefaultAsync(s => s.Isin == isin, ct); + // Coins have no ISIN: the same symbol is the same coin, so a Binance BTC joins a hand-entered BTC. + var security = isin is not null + ? await db.Securities.FirstOrDefaultAsync(s => s.Isin == isin, ct) + : report.AssetClass == AssetClass.Crypto + ? await db.Securities.FirstOrDefaultAsync(s => s.Isin == null && s.AssetClass == AssetClass.Crypto && + s.Symbol == report.Symbol.ToUpper(), ct) + : null; if (security is null) { security = Security.Create(isin, report.Symbol, report.Exchange, report.Name, report.Currency, diff --git a/backend/src/SharedKernel/DataSource.cs b/backend/src/SharedKernel/DataSource.cs index 77b7a4a..d8113b9 100644 --- a/backend/src/SharedKernel/DataSource.cs +++ b/backend/src/SharedKernel/DataSource.cs @@ -12,6 +12,7 @@ public enum DataSource Json = 4, Trading212 = 10, InteractiveBrokers = 11, + Binance = 12, /// Public end-of-day closing prices from the configured market-data provider (never account data). MarketData = 20, diff --git a/backend/tests/Integrations.Tests/BinanceTests.cs b/backend/tests/Integrations.Tests/BinanceTests.cs new file mode 100644 index 0000000..93ffc86 --- /dev/null +++ b/backend/tests/Integrations.Tests/BinanceTests.cs @@ -0,0 +1,165 @@ +using System.Web; +using Integrations.Application.Contracts; +using Integrations.Infrastructure; +using Integrations.Infrastructure.Binance; +using Investments.Domain; +using SharedKernel.Http; + +namespace Integrations.Tests; + +public sealed class BinanceTests +{ + private static long Ms(int month, int day) => new DateTimeOffset(2026, month, day, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(); + + private const string ReadOnlyKey = """ + {"ipRestrict":true,"createTime":1,"enableReading":true,"enableSpotAndMarginTrading":false, + "enableWithdrawals":false,"enableInternalTransfer":false,"enableMargin":false,"enableFutures":false, + "permitsUniversalTransfer":false,"enableVanillaOptions":false,"enablePortfolioMarginTrading":false, + "enableFixApiTrade":false,"enableFixReadOnly":false} + """; + + private const string Account = """ + {"canTrade":true,"balances":[ + {"asset":"BTC","free":"0.10000000","locked":"0.00000000"}, + {"asset":"LDBTC","free":"0.05000000","locked":"0.00000000"}, + {"asset":"EUR","free":"250.50","locked":"0.00"}, + {"asset":"ETH","free":"1.00000000","locked":"0.00000000"}, + {"asset":"XYZ","free":"5.00000000","locked":"0.00000000"}]} + """; + + private const string Prices = """ + [{"symbol":"BTCEUR","price":"60000.00"},{"symbol":"BTCUSDT","price":"66000.00"}, + {"symbol":"ETHUSDT","price":"3300.00"},{"symbol":"EURUSDT","price":"1.1000"}] + """; + + private static string Kline(long openTime, string close) => $"""[{openTime},"1","1","1","{close}","1",{openTime + 86_399_999},"1",1,"1","1","0"]"""; + + private static string Empty => """{"rows":[],"total":0}"""; + + private static (BinanceProvider Provider, ScriptedHandler Http) Provider(string restrictions = ReadOnlyKey) + { + var http = new ScriptedHandler() + .On("/sapi/v1/account/apiRestrictions", restrictions) + .On("/api/v3/account", Account) + .On("/sapi/v1/simple-earn/flexible/position", """{"rows":[{"asset":"BTC","totalAmount":"0.05","latestAnnualPercentageRate":"0.01"}],"total":1}""") + .On("/sapi/v1/simple-earn/locked/position", """{"rows":[{"asset":"ETH","amount":"0.5","positionId":7}],"total":1}""") + .On("/api/v3/ticker/price", Prices) + // BTC: 0.1 bought at 50,000 EUR, half sold (the average stays), then 0.05 bought for 3,300 USDT (3,000 EUR). + .On("/api/v3/myTrades?symbol=BTCEUR", $$""" + [{"id":1,"price":"50000","qty":"0.1","quoteQty":"5000","time":{{Ms(1, 10)}},"isBuyer":true}, + {"id":2,"price":"55000","qty":"0.05","quoteQty":"2750","time":{{Ms(2, 10)}},"isBuyer":false}] + """) + .On("/api/v3/myTrades?symbol=BTCUSDT", $$""" + [{"id":9,"price":"66000","qty":"0.05","quoteQty":"3300","time":{{Ms(9, 21)}},"isBuyer":true}] + """) + .On("/api/v3/myTrades?symbol=ETHUSDT", "[]") + .On("/api/v3/klines?symbol=EURUSDT", $"[{Kline(Ms(9, 21), "1.10")}]") + .On("/api/v3/klines?symbol=BTCEUR", $"[{Kline(Ms(9, 19), "57000")},{Kline(Ms(9, 20), "58000")}]") + .On("/api/v3/klines?symbol=ETHUSDT", $"[{Kline(Ms(9, 21), "3300")}]") + // One reward each, in the first window; every later window is empty. + .On("/sapi/v1/simple-earn/flexible/history/rewardsRecord?type=REALTIME", + $$"""{"rows":[{"asset":"BTC","rewards":"0.0001","projectId":"BTC001","type":"REALTIME","time":{{Ms(9, 20) + 3_600_000}}}],"total":1}""") + .On("/sapi/v1/simple-earn/flexible/history/rewardsRecord?type=REALTIME", Empty) + .On("/sapi/v1/simple-earn/flexible/history/rewardsRecord?type=BONUS", Empty) + .On("/sapi/v1/simple-earn/flexible/history/rewardsRecord?type=REWARDS", Empty) + .On("/sapi/v1/simple-earn/locked/history/rewardsRecord", + $$"""{"rows":[{"positionId":7,"time":{{Ms(9, 21)}},"asset":"ETH","lockPeriod":"30","amount":"0.01","type":"Locked Rewards"}],"total":1}""") + .On("/sapi/v1/simple-earn/locked/history/rewardsRecord", Empty); + var guard = new AllowListHttpHandler(BinanceClient.AllowList()) { InnerHandler = http }; + var clock = new InstantTimeProvider(); + var client = new BinanceClient(new HttpClient(guard), new RateGate(clock), clock).Configure("key", "secret"); + return (new BinanceProvider(client, clock), http); + } + + [Fact] + public async Task One_position_per_coin_with_spot_and_earn_valued_in_euros() + { + var (provider, http) = Provider(); + + var snapshot = await provider.GetAccountSnapshotAsync(CancellationToken.None); + var positions = await provider.GetPositionsAsync(CancellationToken.None); + + snapshot.Currency.ShouldBe("EUR"); + snapshot.Cash.ShouldBe(250.50m); + snapshot.TotalValue.ShouldBe(250.50m + 0.15m * 60_000m + 1.5m * 3_000m); + positions.Select(p => p.Security.Symbol).ShouldBe(["BTC", "ETH"]); // XYZ has no EUR or dollar price + var btc = positions[0]; + btc.Quantity.ShouldBe(0.15m); // spot 0.1 + flexible 0.05; the LDBTC shadow row is not counted again + btc.LastPrice.ShouldBe(60_000m); + btc.AveragePrice.ShouldBe(55_000m); // (2,500 left of the EUR buy + 3,000) / 0.1 + btc.Security.AssetClass.ShouldBe(AssetClass.Crypto); + btc.Security.Currency.ShouldBe("EUR"); + btc.Security.Isin.ShouldBeNull(); + var eth = positions[1]; + eth.Quantity.ShouldBe(1.5m); // spot 1 + locked 0.5 + eth.LastPrice.ShouldBe(3_000m); // 3,300 USDT / 1.10 + eth.AveragePrice.ShouldBe(3_000m); // no fills: valued from now on, without a gain + + http.Requests.ShouldAllBe(r => r.Method == HttpMethod.Get); + var signed = http.Requests.Where(r => r.RequestUri!.AbsolutePath != "/api/v3/ticker/price" && + r.RequestUri.AbsolutePath != "/api/v3/klines").ToList(); + signed.ShouldAllBe(r => r.Headers.GetValues("X-MBX-APIKEY").Single() == "key"); + signed.ShouldAllBe(r => HttpUtility.ParseQueryString(r.RequestUri!.Query)["signature"]!.Length == 64); + } + + [Fact] + public async Task Earn_rewards_are_income_valued_at_the_days_close() + { + var (provider, _) = Provider(); + + var rewards = await provider.GetDividendsAsync(null, CancellationToken.None); + + rewards.Count.ShouldBe(2); + var btc = rewards.Single(r => r.Security.Symbol == "BTC"); + btc.PaidOn.ShouldBe(new DateOnly(2026, 9, 20)); + btc.NetAmount.ShouldBe(5.8m); // 0.0001 × 58,000 + btc.Currency.ShouldBe("EUR"); + btc.Quantity.ShouldBe(0.0001m); + btc.ExternalId.ShouldStartWith("binance:earn:flexible-realtime:BTC:"); + var eth = rewards.Single(r => r.Security.Symbol == "ETH"); + eth.NetAmount.ShouldBe(30m); // 0.01 × (3,300 / 1.10) + eth.ExternalId.ShouldStartWith("binance:earn:locked:ETH:"); + + // Same input, same ids: a re-sync never books a reward twice. + var (again, _) = Provider(); + (await again.GetDividendsAsync(null, CancellationToken.None)).Select(r => r.ExternalId) + .ShouldBe(rewards.Select(r => r.ExternalId), ignoreOrder: true); + } + + [Theory] + [InlineData("enableSpotAndMarginTrading", "spot & margin trading")] + [InlineData("enableWithdrawals", "withdrawals")] + [InlineData("enableInternalTransfer", "transfers")] + [InlineData("enableFutures", "futures")] + public async Task A_key_that_can_do_more_than_read_is_refused_before_reading(string permission, string named) + { + var (provider, http) = Provider(ReadOnlyKey.Replace($"\"{permission}\":false", $"\"{permission}\":true")); + + var error = await Should.ThrowAsync( + () => provider.GetPositionsAsync(CancellationToken.None)); + + error.Message.ShouldContain(named); + http.Requests.Single().RequestUri!.AbsolutePath.ShouldBe("/sapi/v1/account/apiRestrictions"); + } + + [Theory] + [InlineData("POST", "/api/v3/order")] + [InlineData("GET", "/sapi/v1/capital/withdraw/apply")] + [InlineData("POST", "/sapi/v1/simple-earn/flexible/redeem")] + [InlineData("POST", "/sapi/v1/asset/transfer")] + [InlineData("POST", "/sapi/v1/convert/acceptQuote")] + public async Task Only_allow_listed_read_requests_can_leave(string method, string path) + { + var guard = new AllowListHttpHandler(BinanceClient.AllowList()) { InnerHandler = new ScriptedHandler() }; + using var http = new HttpClient(guard); + + await Should.ThrowAsync(() => + http.SendAsync(new HttpRequestMessage(new HttpMethod(method), $"https://{BinanceClient.Host}{path}"))); + } + + [Fact] + public void Average_cost_is_unknown_without_buys() + { + BinanceValuation.AverageCost([], new Dictionary(), 1.1m).ShouldBeNull(); + } +} diff --git a/docs/adr/0009-binance-read-only-key.md b/docs/adr/0009-binance-read-only-key.md new file mode 100644 index 0000000..34c6772 --- /dev/null +++ b/docs/adr/0009-binance-read-only-key.md @@ -0,0 +1,28 @@ +# ADR-0009: Binance through a key the app verifies as read-only + +- Status: Accepted +- Date: 2026-10-06 +- Extends: [ADR-0005](0005-read-only-broker-integrations.md) + +## Context +Coins on Binance were entered by hand, including Simple Earn balances and their daily rewards, which change every +day. The owner asked for them to be synced like Trading 212 and IBKR. Binance's API is a trading API: the same kind of +key can place orders, transfer between wallets and withdraw. ADR-0005 rests on credentials that cannot trade, so +connecting Binance has to keep that property. + +## Decision +- Connect Binance with an API key that has only **"Enable Reading"**. Unlike Trading 212 (where the owner is trusted to + leave the permissions off), the app **checks**: every sync starts with `GET /sapi/v1/account/apiRestrictions` and + stops with a configuration error if the key can trade (spot, margin, futures, options, portfolio margin, FIX), + transfer or withdraw. The check runs before any account data is read. +- The `HttpClient` is allow-listed (ADR-0005) to `GET` on: API restrictions, account balances, fill history, Simple + Earn positions and reward history, public prices and daily candles. No order, convert, subscribe/redeem, transfer + or withdrawal path is reachable. +- Scope: spot + Simple Earn positions (one position per coin), cost from fills, Earn rewards as income. Trades and + deposits/withdrawals are not imported (dollar-stablecoin pairs do not map to the ledger's currencies). +- Other exchanges and wallets stay hand-entered. + +## Consequences +A leaked database or server still cannot move funds: the stored key is read-only, and a key that is not is never +used. The owner must keep the key IP-restricted (Binance otherwise lets an unrestricted key expire). Cost basis is +unknown for coins that were not bought on Binance with EUR or dollar stablecoins; they show no gain. diff --git a/docs/broker-integrations.md b/docs/broker-integrations.md index edb1d1e..1920dc9 100644 --- a/docs/broker-integrations.md +++ b/docs/broker-integrations.md @@ -159,9 +159,38 @@ fails because of it. **Only ISINs, listing symbols and date ranges leave the ser values or account data. The Yahoo endpoints are unofficial and may change or be rate limited; set `none` if you prefer no third-party requests. +## Binance — Spot and Simple Earn (read-only key) + +Binance is the one exchange that can be connected, because its API keys can be made read-only and the app can check +that they are. Everything else (other exchanges, hardware wallets) is [entered by hand](#crypto-entered-by-hand). + +1. Binance → *Account → API Management → Create API → System generated*. Leave **only "Enable Reading"** ticked + (no spot/margin trading, futures, options, transfers or withdrawals) and restrict access to your home IP. +2. *Connections → Add connection → Binance*: paste the API Key and Secret Key. + +- **Read-only by construction** ([ADR-0009](adr/0009-binance-read-only-key.md)). Before reading anything, each sync + asks Binance what the key may do (`GET /sapi/v1/account/apiRestrictions`) and refuses a key that can trade, + transfer or withdraw. The HTTP client is allow-listed to `GET` requests on the account, Simple Earn positions and + rewards, fill history, prices and daily candles; an order, convert, redeem, transfer or withdrawal request is + blocked before it leaves the process. Requests are signed (HMAC-SHA256) and the secret is stored encrypted like + the other brokers' credentials. +- **Positions.** One position per coin: spot balance + flexible Earn + locked Earn, valued in EUR at Binance's + price (the EUR pair, else the USDT/USDC/FDUSD pair converted with EURUSDT). EUR balances are the account's cash. + Coins Binance cannot price in EUR or dollars are skipped. A coin held both on Binance and by hand is one position + line (coins have no ISIN, so the symbol identifies them). +- **Cost.** The average buy price comes from the account's fills on EUR and dollar-stablecoin pairs (moving average; + dollar fills converted at that day's EURUSDT close). Coins that were never bought there (deposited, converted or + earned) have no known cost: they are valued from the first sync on, without a gain. +- **Earn rewards** (flexible real-time APR, bonus tiers, locked rewards) are income, like dividends: one entry per + reward, valued at the coin's EUR close of that day, under *Dividends & rewards*. The first sync reads the last + 180 days; later syncs read from the previous one, and the external id + (`binance:earn:::