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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum BrokerKind
{
Trading212 = 0,
InteractiveBrokers = 1,
Binance = 2,
Demo = 99,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ public async Task<SyncResult> RunAsync(Guid connectionId, SyncTrigger trigger, C
{
BrokerKind.Trading212 => DataSource.Trading212,
BrokerKind.InteractiveBrokers => DataSource.InteractiveBrokers,
BrokerKind.Binance => DataSource.Binance,
_ => DataSource.Demo,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="EnsureReadOnlyKeyAsync"/> refuses any key that
/// Binance reports as able to trade or move funds.
/// </summary>
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,
};

/// <summary>Everything the integration may call; any other request is blocked before it leaves the process.</summary>
public static IEnumerable<AllowedRequest> 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"),
];

/// <summary>Request weight is generous (6,000/min); spacing keeps a sync far below it.</summary>
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;
}

/// <summary>
/// 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.
/// </summary>
public async Task EnsureReadOnlyKeyAsync(CancellationToken ct)
{
var r = await SignedAsync<BinanceApiRestrictions>("/sapi/v1/account/apiRestrictions", "", ct);
var extra = new List<string>();
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<BinanceAccount> AccountAsync(CancellationToken ct) =>
SignedAsync<BinanceAccount>("/api/v3/account", "omitZeroBalances=true", ct);

public Task<List<BinanceFlexiblePosition>> FlexiblePositionsAsync(CancellationToken ct) =>
PagedAsync<BinanceFlexiblePosition>("/sapi/v1/simple-earn/flexible/position", "", ct);

public Task<List<BinanceLockedPosition>> LockedPositionsAsync(CancellationToken ct) =>
PagedAsync<BinanceLockedPosition>("/sapi/v1/simple-earn/locked/position", "", ct);

/// <summary>Flexible rewards of one kind (REALTIME, BONUS or REWARDS) between two instants.</summary>
public Task<List<BinanceFlexibleReward>> FlexibleRewardsAsync(string type, DateTimeOffset from, DateTimeOffset to,
CancellationToken ct) =>
PagedAsync<BinanceFlexibleReward>("/sapi/v1/simple-earn/flexible/history/rewardsRecord",
$"type={type}&startTime={from.ToUnixTimeMilliseconds()}&endTime={to.ToUnixTimeMilliseconds()}", ct);

public Task<List<BinanceLockedReward>> LockedRewardsAsync(DateTimeOffset from, DateTimeOffset to,
CancellationToken ct) =>
PagedAsync<BinanceLockedReward>("/sapi/v1/simple-earn/locked/history/rewardsRecord",
$"startTime={from.ToUnixTimeMilliseconds()}&endTime={to.ToUnixTimeMilliseconds()}", ct);

/// <summary>Every fill of the account on <paramref name="symbol"/>, oldest first (paged by fill id).</summary>
public async Task<List<BinanceFill>> FillsAsync(string symbol, CancellationToken ct)
{
var fills = new List<BinanceFill>();
long fromId = 0;
for (var page = 0; page < 200; page++)
{
var batch = await SignedAsync<List<BinanceFill>>("/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;
}

/// <summary>Latest price of every symbol (public).</summary>
public async Task<Dictionary<string, decimal>> PricesAsync(CancellationToken ct) =>
(await GetAsync<List<BinanceTicker>>("/api/v3/ticker/price", ct))
.GroupBy(t => t.Symbol).ToDictionary(g => g.Key, g => g.First().Price, StringComparer.Ordinal);

/// <summary>Daily closes of <paramref name="symbol"/> from <paramref name="from"/> (UTC days, public).</summary>
public async Task<Dictionary<DateOnly, decimal>> DailyClosesAsync(string symbol, DateOnly from, CancellationToken ct)
{
var closes = new Dictionary<DateOnly, decimal>();
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<List<JsonElement[]>>(
$"/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<List<T>> PagedAsync<T>(string path, string query, CancellationToken ct)
{
var rows = new List<T>();
for (var current = 1; current <= 50; current++)
{
var q = (query.Length > 0 ? query + "&" : "") + $"current={current}&size=100";
var page = await SignedAsync<BinancePage<T>>(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<T> SignedAsync<T>(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&timestamp={clock.GetUtcNow().ToUnixTimeMilliseconds()}";
var signature = Convert.ToHexStringLower(HMACSHA256.HashData(_secret, Encoding.UTF8.GetBytes(payload)));
return SendAsync<T>($"{path}?{payload}&signature={signature}", _apiKey, ct);
}

private Task<T> GetAsync<T>(string pathAndQuery, CancellationToken ct) => SendAsync<T>(pathAndQuery, null, ct);

private async Task<T> SendAsync<T>(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<T>(Json, ct)
?? throw new ProviderUnavailableException("Empty response from Binance.");
}

BinanceError? error = null;
try
{
error = await response.Content.ReadFromJsonAsync<BinanceError>(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);
}
Original file line number Diff line number Diff line change
@@ -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<BinanceBalance> Balances);

internal sealed record BinancePage<T>(List<T>? Rows, int Total);

internal sealed record BinanceFlexiblePosition(string Asset, decimal TotalAmount);

internal sealed record BinanceLockedPosition(string Asset, decimal Amount);

/// <summary>A flexible-product reward: <c>rewards</c> of <c>asset</c> at <c>time</c> (ms).</summary>
internal sealed record BinanceFlexibleReward(string Asset, decimal Rewards, string? Type, long Time);

/// <summary>A locked-product reward: <c>amount</c> of <c>asset</c> at <c>time</c> (ms).</summary>
internal sealed record BinanceLockedReward(string Asset, decimal Amount, string? Type, long Time, long? PositionId);

internal sealed record BinanceTicker(string Symbol, decimal Price);

/// <summary>An executed fill of the account on one symbol (GET /api/v3/myTrades).</summary>
internal sealed record BinanceFill(long Id, decimal Price, decimal Qty, decimal QuoteQty, long Time, bool IsBuyer);

internal sealed record BinanceError(int Code, string? Msg);
Loading
Loading