From a955ab3b325e176a4512df5980a28e039e6a25fc Mon Sep 17 00:00:00 2001 From: caspar Date: Sat, 21 Mar 2026 09:16:59 -0400 Subject: [PATCH] Secured API with key. --- Backend.cs | 13 +- Controller/AnnotationController.cs | 79 +++++------- Controller/MarketDataController.cs | 109 +++++----------- Controller/NewsSentimentController.cs | 98 +++++---------- Controller/UserInterestController.cs | 46 ++----- Controller/WeatherController.cs | 84 +++++-------- DatabaseHandler/NewsSentimentRecord.cs | 22 ++++ DatabaseHandler/PostgresApiKeyStore.cs | 33 +++++ Interface/IAlertEvaluator.cs | 2 +- Interface/IApiKeyStore.cs | 6 + MarketDataRequest/LiveEquitySnapshot.cs | 6 + Middleware/ApiKeyMiddleware.cs | 42 +++++++ Middleware/GlobalExceptionFilter.cs | 34 +++++ Migrations/001_create_api_keys.sql | 12 ++ README.md | 117 +++++++++++++++++- ServiceHandler/AlertEvaluationService.cs | 2 +- ServiceHandler/EdgarFilingService.cs | 2 +- ServiceHandler/FinBertScoringService.cs | 2 +- ServiceHandler/MarketDataHander.cs | 2 +- ServiceHandler/NewsSentimentRefreshService.cs | 2 +- ServiceHandler/NewsSentimentService.cs | 2 +- ServiceHandler/PercentChangeEvaluator.cs | 2 +- ServiceHandler/PriceSpikeEvaluator.cs | 2 +- ServiceHandler/PriceThresholdEvaluator.cs | 2 +- ServiceHandler/TrailingPriceEvaluator.cs | 2 +- ServiceHandler/UserInterestsHandler.cs | 17 +-- ServiceHandler/WeatherService.cs | 23 +++- appsettings.Development.json | 2 +- appsettings.json | 4 +- 29 files changed, 454 insertions(+), 315 deletions(-) create mode 100644 DatabaseHandler/PostgresApiKeyStore.cs create mode 100644 Interface/IApiKeyStore.cs create mode 100644 Middleware/ApiKeyMiddleware.cs create mode 100644 Middleware/GlobalExceptionFilter.cs create mode 100644 Migrations/001_create_api_keys.sql diff --git a/Backend.cs b/Backend.cs index b9e9a4a..b220e2a 100644 --- a/Backend.cs +++ b/Backend.cs @@ -1,11 +1,17 @@ using Backend.DatabaseHandler; -using Backend.ServiceHander; +using Backend.ServiceHandler; using Backend.Interface; +using Backend.Middleware; using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); +builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); + +builder.Services.AddControllers(options => +{ + options.Filters.Add(); +}); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -23,6 +29,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -59,6 +67,7 @@ app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseCors("Frontend"); +app.UseMiddleware(); app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/Controller/AnnotationController.cs b/Controller/AnnotationController.cs index ad43567..d21d567 100644 --- a/Controller/AnnotationController.cs +++ b/Controller/AnnotationController.cs @@ -1,7 +1,7 @@ using Backend.Interface; using Microsoft.AspNetCore.Mvc; -namespace Backend.Controller +namespace Backend.Controllers { [ApiController] [Route("api/annotations")] @@ -20,25 +20,18 @@ namespace Backend.Controller [FromQuery] string symbol, CancellationToken cancellationToken = default) { - try - { - if (string.IsNullOrWhiteSpace(user)) - return BadRequest("User cannot be empty."); - if (string.IsNullOrWhiteSpace(symbol)) - return BadRequest("Symbol cannot be empty."); + if (string.IsNullOrWhiteSpace(user)) + return BadRequest("User cannot be empty."); + if (string.IsNullOrWhiteSpace(symbol)) + return BadRequest("Symbol cannot be empty."); - var json = await _store.GetAsync( - user.Trim(), symbol.Trim().ToUpperInvariant(), cancellationToken); + var json = await _store.GetAsync( + user.Trim(), symbol.Trim().ToUpperInvariant(), cancellationToken); - if (json is null) - return Ok("[]"); + if (json is null) + return Ok("[]"); - return Content(json, "application/json"); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Content(json, "application/json"); } [HttpPost("save")] @@ -47,52 +40,38 @@ namespace Backend.Controller [FromQuery] string symbol, CancellationToken cancellationToken = default) { - try - { - if (string.IsNullOrWhiteSpace(user)) - return BadRequest("User cannot be empty."); - if (string.IsNullOrWhiteSpace(symbol)) - return BadRequest("Symbol cannot be empty."); + if (string.IsNullOrWhiteSpace(user)) + return BadRequest("User cannot be empty."); + if (string.IsNullOrWhiteSpace(symbol)) + return BadRequest("Symbol cannot be empty."); - using var reader = new StreamReader(Request.Body); - string body = await reader.ReadToEndAsync(cancellationToken); + using var reader = new StreamReader(Request.Body); + string body = await reader.ReadToEndAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(body)) - return BadRequest("Request body cannot be empty."); + if (string.IsNullOrWhiteSpace(body)) + return BadRequest("Request body cannot be empty."); - await _store.UpsertAsync( - user.Trim(), symbol.Trim().ToUpperInvariant(), body, cancellationToken); + await _store.UpsertAsync( + user.Trim(), symbol.Trim().ToUpperInvariant(), body, cancellationToken); - return Ok(new { success = true }); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Ok(new { success = true }); } - [HttpGet("delete")] + [HttpDelete("delete")] public async Task Delete( [FromQuery] string user, [FromQuery] string symbol, CancellationToken cancellationToken = default) { - try - { - if (string.IsNullOrWhiteSpace(user)) - return BadRequest("User cannot be empty."); - if (string.IsNullOrWhiteSpace(symbol)) - return BadRequest("Symbol cannot be empty."); + if (string.IsNullOrWhiteSpace(user)) + return BadRequest("User cannot be empty."); + if (string.IsNullOrWhiteSpace(symbol)) + return BadRequest("Symbol cannot be empty."); - await _store.DeleteAsync( - user.Trim(), symbol.Trim().ToUpperInvariant(), cancellationToken); + await _store.DeleteAsync( + user.Trim(), symbol.Trim().ToUpperInvariant(), cancellationToken); - return Ok(new { success = true }); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Ok(new { success = true }); } } } diff --git a/Controller/MarketDataController.cs b/Controller/MarketDataController.cs index c8f0bd8..09028c8 100644 --- a/Controller/MarketDataController.cs +++ b/Controller/MarketDataController.cs @@ -1,10 +1,10 @@ -using Backend.ServiceHander; +using Backend.ServiceHandler; using Backend.MarketDataRequest; using Backend.Interface; using Microsoft.AspNetCore.Mvc; using System.Globalization; -namespace Backend.Controller +namespace Backend.Controllers { [ApiController] [Route("api/market")] @@ -24,60 +24,31 @@ namespace Backend.Controller [FromQuery] string symbol = "SPY", CancellationToken cancellationToken = default) { - try - { - var lastDay = await _cache.GetLastTradingDayAsync( - symbol.Trim().ToUpperInvariant(), cancellationToken); + var lastDay = await _cache.GetLastTradingDayAsync( + symbol.Trim().ToUpperInvariant(), cancellationToken); - if (lastDay is null) - return Ok(new { found = false, lastTradingDay = (string?)null }); + if (lastDay is null) + return Ok(new { found = false, lastTradingDay = (string?)null }); - return Ok(new - { - found = true, - lastTradingDay = lastDay.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture) - }); - } - catch (Exception ex) + return Ok(new { - return StatusCode(500, new { error = ex.ToString() }); - } + found = true, + lastTradingDay = lastDay.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture) + }); } [HttpGet("symbol-check/{symbol}")] public IActionResult CheckSymbol(string symbol) { - try - { - var (found, message) = _marketDataService.CheckSymbol(symbol.ToUpperInvariant()); - return Ok(new { found, message }); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + var (found, message) = _marketDataService.CheckSymbol(symbol.ToUpperInvariant()); + return Ok(new { found, message }); } [HttpGet("live/{symbol}")] public IActionResult GetLive(string symbol) { - try - { - var data = _marketDataService.GetLiveQuote(symbol.ToUpperInvariant()); - return Ok(new - { - symbol = data.Symbol, - lastPrice = data.LastPrice, - volume = data.Volume, - previousClose = data.PreviousClose, - openPrice = data.Open, - marketOpen = data.MarketOpen - }); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + var data = _marketDataService.GetLiveQuote(symbol.ToUpperInvariant()); + return Ok(data); } [HttpGet("historical")] @@ -89,23 +60,16 @@ namespace Backend.Controller [FromQuery] HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades, [FromQuery] bool useRth = true) { - try - { - var data = await _marketDataService.GetHistoricalAsync( - symbol.ToUpperInvariant(), - endDateTime, - duration, - barSize, - whatToShow, - useRth - ); + var data = await _marketDataService.GetHistoricalAsync( + symbol.ToUpperInvariant(), + endDateTime, + duration, + barSize, + whatToShow, + useRth + ); - return Ok(data); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Ok(data); } [HttpGet("historical-line")] @@ -117,23 +81,16 @@ namespace Backend.Controller [FromQuery] HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades, [FromQuery] bool useRth = true) { - try - { - var data = await _marketDataService.GetHistoricalLineAsync( - symbol.ToUpperInvariant(), - endDateTime, - duration, - barSize, - whatToShow, - useRth - ); + var data = await _marketDataService.GetHistoricalLineAsync( + symbol.ToUpperInvariant(), + endDateTime, + duration, + barSize, + whatToShow, + useRth + ); - return Ok(data); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Ok(data); } } -} \ No newline at end of file +} diff --git a/Controller/NewsSentimentController.cs b/Controller/NewsSentimentController.cs index 6ef7d4c..0da9703 100644 --- a/Controller/NewsSentimentController.cs +++ b/Controller/NewsSentimentController.cs @@ -1,8 +1,9 @@ +using Backend.DatabaseHandler; using Backend.Interface; -using Backend.ServiceHander; +using Backend.ServiceHandler; using Microsoft.AspNetCore.Mvc; -namespace Backend.Controller +namespace Backend.Controllers { [ApiController] [Route("api/news-sentiment")] @@ -29,29 +30,8 @@ namespace Backend.Controller [FromQuery] string keyword, CancellationToken cancellationToken) { - try - { - var results = await _service.AnalyzeAsync(keyword, cancellationToken); - return Ok(results.Select(r => new - { - source = r.Source, - keyword = r.Keyword, - publishedAt = r.PublishedAt, - title = r.Title, - score = r.Score, - sentimentLabel = r.SentimentLabel, - publisher = r.Publisher, - url = r.Url - })); - } - catch (ArgumentException ex) - { - return BadRequest(ex.Message); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + var results = await _service.AnalyzeAsync(keyword, cancellationToken); + return Ok(results); } [HttpGet("feed")] @@ -61,50 +41,33 @@ namespace Backend.Controller [FromQuery(Name = "request-amount")] int requestAmount = 20, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrWhiteSpace(user)) + return BadRequest("User cannot be empty."); + + if (requestAmount < 1) + requestAmount = 1; + + List keywords; + + if (!string.IsNullOrWhiteSpace(keyword)) { - if (string.IsNullOrWhiteSpace(user)) - return BadRequest("User cannot be empty."); - - if (requestAmount < 1) - requestAmount = 1; - - List keywords; - - if (!string.IsNullOrWhiteSpace(keyword)) - { - keywords = [keyword.Trim()]; - } - else - { - var symbols = await _interests.FetchAllAsync(user, "symbol"); - var topics = await _interests.FetchAllAsync(user, "topic"); - keywords = symbols.Concat(topics).Distinct().ToList(); - } - - if (keywords.Count == 0) - return Ok(Array.Empty()); - - var results = await _store.GetFeedAsync(keywords, requestAmount, cancellationToken); - - return Ok(results.Select(r => new - { - title = r.Title, - publisher = r.Publisher, - score = r.Score, - publishedAt = r.PublishedAt.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"), - url = r.Url - })); + keywords = [keyword.Trim()]; } - catch (ArgumentException ex) + else { - return BadRequest(ex.Message); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); + var symbols = await _interests.FetchAllAsync(user, "symbol"); + var topics = await _interests.FetchAllAsync(user, "topic"); + keywords = symbols.Concat(topics).Distinct().ToList(); } + + if (keywords.Count == 0) + return Ok(Array.Empty()); + + var results = await _store.GetFeedAsync(keywords, requestAmount, cancellationToken); + + return Ok(results.Select(NewsFeedItem.From)); } + [HttpGet("refresh")] public async Task> Refresh( [FromQuery] string user, @@ -115,7 +78,6 @@ namespace Backend.Controller if (string.IsNullOrWhiteSpace(user)) return BadRequest("User cannot be empty."); - // 5 second refresh cooldown - abuse protection lock (_refreshLock) { if ((DateTime.UtcNow - _lastRefresh).TotalSeconds < 5) @@ -125,11 +87,11 @@ namespace Backend.Controller var symbols = await _interests.FetchAllAsync(user, "symbol"); var topics = await _interests.FetchAllAsync(user, "topic"); - var keywords = symbols.Concat(topics).Distinct().ToList(); + var kws = symbols.Concat(topics).Distinct().ToList(); - foreach (var keyword in keywords) + foreach (var kw in kws) { - await _service.AnalyzeAsync(keyword, cancellationToken); + await _service.AnalyzeAsync(kw, cancellationToken); } return Ok(new BoolResponse { Success = true }); diff --git a/Controller/UserInterestController.cs b/Controller/UserInterestController.cs index f46ba06..bf9c0a2 100644 --- a/Controller/UserInterestController.cs +++ b/Controller/UserInterestController.cs @@ -1,5 +1,4 @@ -using Backend.DatabaseHandler; -using Backend.ServiceHander; +using Backend.ServiceHandler; using Backend.Interface; using Microsoft.AspNetCore.Mvc; @@ -21,52 +20,31 @@ namespace Backend.Controllers [FromQuery] string user, [FromQuery] string type) { - try + List items = await _store.FetchAllAsync(user, type); + return Ok(new UserInterestFetchResponse { - List items = await _store.FetchAllAsync(user, type); - return Ok(new UserInterestFetchResponse - { - Items = items - }); - } - catch (ArgumentException ex) - { - return BadRequest(ex.Message); - } + Items = items + }); } - [HttpGet("add")] + [HttpPost("add")] public async Task> Add( [FromQuery] string user, [FromQuery] string type, [FromQuery] string value) { - try - { - bool success = await _store.AddAsync(user, type, value); - return Ok(new BoolResponse { Success = success }); - } - catch (ArgumentException ex) - { - return BadRequest(ex.Message); - } + bool success = await _store.AddAsync(user, type, value); + return Ok(new BoolResponse { Success = success }); } - [HttpGet("remove")] + [HttpDelete("remove")] public async Task> Remove( [FromQuery] string user, [FromQuery] string type, [FromQuery] string value) { - try - { - bool success = await _store.RemoveAsync(user, type, value); - return Ok(new BoolResponse { Success = success }); - } - catch (ArgumentException ex) - { - return BadRequest(ex.Message); - } + bool success = await _store.RemoveAsync(user, type, value); + return Ok(new BoolResponse { Success = success }); } } -} \ No newline at end of file +} diff --git a/Controller/WeatherController.cs b/Controller/WeatherController.cs index 2e2d2e0..9d55fd3 100644 --- a/Controller/WeatherController.cs +++ b/Controller/WeatherController.cs @@ -1,9 +1,9 @@ using Backend.DatabaseHandler; using Backend.Interface; -using Backend.ServiceHander; +using Backend.ServiceHandler; using Microsoft.AspNetCore.Mvc; -namespace Backend.Controller +namespace Backend.Controllers { [ApiController] [Route("api/weather")] @@ -18,39 +18,32 @@ namespace Backend.Controller _weather = weather; } - [HttpGet("set-location")] + [HttpPut("set-location")] public async Task SetLocation( [FromQuery] string user, [FromQuery] string city, CancellationToken cancellationToken = default) { - try + if (string.IsNullOrWhiteSpace(user)) + return BadRequest("User cannot be empty."); + if (string.IsNullOrWhiteSpace(city)) + return BadRequest("City cannot be empty."); + + var geo = await _weather.GeocodeAsync(city, cancellationToken); + if (geo is null) + return BadRequest("City not found."); + + var (resolvedCity, lat, lng) = geo.Value; + + await _locationStore.UpsertAsync(new UserLocationRecord { - if (string.IsNullOrWhiteSpace(user)) - return BadRequest("User cannot be empty."); - if (string.IsNullOrWhiteSpace(city)) - return BadRequest("City cannot be empty."); + Username = user.Trim(), + City = resolvedCity, + Latitude = lat, + Longitude = lng + }, cancellationToken); - var geo = await _weather.GeocodeAsync(city, cancellationToken); - if (geo is null) - return BadRequest("City not found."); - - var (resolvedCity, lat, lng) = geo.Value; - - await _locationStore.UpsertAsync(new UserLocationRecord - { - Username = user.Trim(), - City = resolvedCity, - Latitude = lat, - Longitude = lng - }, cancellationToken); - - return Ok(new { city = resolvedCity, latitude = lat, longitude = lng }); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Ok(new { city = resolvedCity, latitude = lat, longitude = lng }); } [HttpGet("current")] @@ -58,35 +51,20 @@ namespace Backend.Controller [FromQuery] string user, CancellationToken cancellationToken = default) { - try - { - if (string.IsNullOrWhiteSpace(user)) - return BadRequest("User cannot be empty."); + if (string.IsNullOrWhiteSpace(user)) + return BadRequest("User cannot be empty."); - var location = await _locationStore.GetAsync(user.Trim(), cancellationToken); - if (location is null) - return BadRequest("No location set. Use /api/weather/set-location first."); + var location = await _locationStore.GetAsync(user.Trim(), cancellationToken); + if (location is null) + return BadRequest("No location set. Use /api/weather/set-location first."); - var weather = await _weather.GetCurrentWeatherAsync( - location.Latitude, location.Longitude, cancellationToken); + var weather = await _weather.GetCurrentWeatherAsync( + location.Latitude, location.Longitude, cancellationToken); - if (weather is null) - return StatusCode(500, new { error = "Failed to fetch weather data." }); + if (weather is null) + return StatusCode(500, new { error = "Failed to fetch weather data." }); - return Ok(new - { - city = location.City, - temperatureF = weather.TemperatureF, - humidityPercent = weather.HumidityPercent, - windSpeedMph = weather.WindSpeedMph, - weatherCode = weather.WeatherCode, - description = weather.Description - }); - } - catch (Exception ex) - { - return StatusCode(500, new { error = ex.ToString() }); - } + return Ok(CurrentWeatherResponse.From(location, weather)); } } } diff --git a/DatabaseHandler/NewsSentimentRecord.cs b/DatabaseHandler/NewsSentimentRecord.cs index 96dd5be..c796855 100644 --- a/DatabaseHandler/NewsSentimentRecord.cs +++ b/DatabaseHandler/NewsSentimentRecord.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Backend.DatabaseHandler { public sealed class NewsSentimentRecord @@ -8,8 +10,28 @@ namespace Backend.DatabaseHandler public string Title { get; init; } = string.Empty; public double Score { get; init; } public string SentimentLabel { get; init; } = string.Empty; + + [JsonIgnore] public string TitleHash { get; init; } = string.Empty; public string Publisher { get; init; } = string.Empty; public string Url { get; init; } = string.Empty; } + + public sealed class NewsFeedItem + { + public string Title { get; init; } = string.Empty; + public string Publisher { get; init; } = string.Empty; + public double Score { get; init; } + public string PublishedAt { get; init; } = string.Empty; + public string Url { get; init; } = string.Empty; + + public static NewsFeedItem From(NewsSentimentRecord r) => new() + { + Title = r.Title, + Publisher = r.Publisher, + Score = r.Score, + PublishedAt = r.PublishedAt.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"), + Url = r.Url + }; + } } diff --git a/DatabaseHandler/PostgresApiKeyStore.cs b/DatabaseHandler/PostgresApiKeyStore.cs new file mode 100644 index 0000000..a429030 --- /dev/null +++ b/DatabaseHandler/PostgresApiKeyStore.cs @@ -0,0 +1,33 @@ +using Npgsql; +using Backend.Interface; + +namespace Backend.DatabaseHandler; + +public sealed class PostgresApiKeyStore : IApiKeyStore +{ + private readonly string _connectionString; + + public PostgresApiKeyStore(IConfiguration configuration) + { + _connectionString = configuration.GetConnectionString("MarketDataDb") + ?? throw new InvalidOperationException("Missing connection string: MarketDataDb"); + } + + public async Task ValidateAsync(string apiKey, CancellationToken cancellationToken = default) + { + const string sql = """ + SELECT COUNT(1) + FROM api_keys + WHERE key = @key AND is_active = TRUE; + """; + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(cancellationToken); + + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("key", apiKey); + + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt64(result) > 0; + } +} diff --git a/Interface/IAlertEvaluator.cs b/Interface/IAlertEvaluator.cs index 621e833..5031079 100644 --- a/Interface/IAlertEvaluator.cs +++ b/Interface/IAlertEvaluator.cs @@ -1,5 +1,5 @@ using Backend.DatabaseHandler; -using Backend.ServiceHander; +using Backend.ServiceHandler; namespace Backend.Interface; diff --git a/Interface/IApiKeyStore.cs b/Interface/IApiKeyStore.cs new file mode 100644 index 0000000..80b6cf3 --- /dev/null +++ b/Interface/IApiKeyStore.cs @@ -0,0 +1,6 @@ +namespace Backend.Interface; + +public interface IApiKeyStore +{ + Task ValidateAsync(string apiKey, CancellationToken cancellationToken = default); +} diff --git a/MarketDataRequest/LiveEquitySnapshot.cs b/MarketDataRequest/LiveEquitySnapshot.cs index 2830a2f..9b51195 100644 --- a/MarketDataRequest/LiveEquitySnapshot.cs +++ b/MarketDataRequest/LiveEquitySnapshot.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Backend.MarketDataRequest { public sealed class LiveEquitySnapshot @@ -7,15 +9,19 @@ namespace Backend.MarketDataRequest public double? LastPrice { get; set; } public long? Volume { get; set; } public double? PreviousClose { get; set; } + + [JsonPropertyName("openPrice")] public double? Open { get; set; } public bool MarketOpen { get; set; } = true; + [JsonIgnore] public bool IsComplete => LastPrice.HasValue && Volume.HasValue && PreviousClose.HasValue && Open.HasValue; + [JsonIgnore] public bool HasMinimumData => LastPrice.HasValue && PreviousClose.HasValue; diff --git a/Middleware/ApiKeyMiddleware.cs b/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 0000000..4d29492 --- /dev/null +++ b/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,42 @@ +using Backend.Interface; + +namespace Backend.Middleware; + +public sealed class ApiKeyMiddleware +{ + private const string ApiKeyHeader = "X-Api-Key"; + private readonly RequestDelegate _next; + + public ApiKeyMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IApiKeyStore apiKeyStore) + { + var path = context.Request.Path.Value ?? ""; + + if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var extractedKey) + || string.IsNullOrWhiteSpace(extractedKey)) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { error = "API key is required." }); + return; + } + + if (!await apiKeyStore.ValidateAsync(extractedKey!, context.RequestAborted)) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsJsonAsync(new { error = "Invalid API key." }); + return; + } + + await _next(context); + } +} diff --git a/Middleware/GlobalExceptionFilter.cs b/Middleware/GlobalExceptionFilter.cs new file mode 100644 index 0000000..57a81d8 --- /dev/null +++ b/Middleware/GlobalExceptionFilter.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Backend.Middleware; + +public sealed class GlobalExceptionFilter : IExceptionFilter +{ + private readonly ILogger _logger; + + public GlobalExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + _logger.LogError(context.Exception, "Unhandled exception on {Method} {Path}", + context.HttpContext.Request.Method, + context.HttpContext.Request.Path); + + if (context.Exception is ArgumentException) + { + context.Result = new BadRequestObjectResult(context.Exception.Message); + context.ExceptionHandled = true; + return; + } + + context.Result = new ObjectResult(new { error = context.Exception.ToString() }) + { + StatusCode = 500 + }; + context.ExceptionHandled = true; + } +} diff --git a/Migrations/001_create_api_keys.sql b/Migrations/001_create_api_keys.sql new file mode 100644 index 0000000..172b2cc --- /dev/null +++ b/Migrations/001_create_api_keys.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS api_keys ( + id BIGSERIAL PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Generate a default key (replace with your own or delete after creating real keys) +INSERT INTO api_keys (key, name) +VALUES (encode(gen_random_bytes(32), 'hex'), 'default') +ON CONFLICT DO NOTHING; diff --git a/README.md b/README.md index be57b1f..bb8fffe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,116 @@ -# SoftTraderBackend +# SoftTrader Backend -The backend for a lite Bloomberg terminal. \ No newline at end of file +A .NET 10 Web API backend for a lite Bloomberg-style terminal. Provides live and historical equity data via Interactive Brokers, AI-driven news sentiment analysis using FinBERT, configurable price alerts, and supporting services like weather and chart annotations. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [PostgreSQL](https://www.postgresql.org/) +- [Interactive Brokers Gateway](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) running on `127.0.0.1:4002` +- FinBERT ONNX model files in `AIModels/finbert/` (`model.onnx`, `vocab.txt`, tokenizer configs) + +## Getting Started + +1. **Configure the database** — update the connection string in `appsettings.json`: + ```json + "ConnectionStrings": { + "MarketDataDb": "Host=127.0.0.1;Port=5432;Database=softtraderbackend_pricedata;Username=postgres;Password=" + } + ``` + +2. **Start IB Gateway** — the application connects on startup and will exit if the connection fails. + +3. **Run the application:** + ```bash + dotnet run + ``` + The API starts on `http://localhost:5000` by default. + +4. **Swagger UI** is available at `/swagger` for interactive API exploration (no API key required). + +## Authentication + +All API endpoints (except Swagger) require an API key via the `X-Api-Key` header. Keys are validated against PostgreSQL. + +## API Endpoints + +### Market Data — `/api/market` + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/live/{symbol}` | Live quote from IB Gateway | +| GET | `/historical` | Historical OHLCV bars (configurable duration, bar size, data type) | +| GET | `/historical-line` | Historical line data | +| GET | `/symbol-check/{symbol}` | Validate a ticker symbol | +| GET | `/last-trading-day` | Last trading day for a symbol | + +### News Sentiment — `/api/news-sentiment` + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/` | Analyze sentiment for a keyword (fetches Google News RSS, scores with FinBERT) | +| GET | `/feed` | Personalized news feed based on user interests | +| GET | `/refresh` | Refresh sentiment data for all of a user's tracked symbols/topics | + +### Alerts — `/api/alerts` + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/create` | Create a price alert | +| GET | `/list` | List all alerts for a user | +| GET | `/triggered` | List triggered alerts for a user | +| DELETE | `/delete` | Delete an alert | + +**Alert types:** `price_threshold`, `percent_change`, `price_spike`, `trailing_price` + +Alerts are evaluated by a background service every minute during market hours (Mon–Fri, 6 AM – 2 PM Pacific). + +### User Interests — `/api/user-interest` + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/fetch` | Get a user's tracked symbols or topics | +| POST | `/add` | Add a symbol or topic | +| DELETE | `/remove` | Remove a symbol or topic | + +### Annotations — `/api/annotations` + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/fetch` | Get saved chart annotations for a user/symbol | +| POST | `/save` | Save chart annotations | +| DELETE | `/delete` | Delete chart annotations | + +### Weather — `/api/weather` + +| Method | Route | Description | +|--------|-------|-------------| +| PUT | `/set-location` | Set a user's city (geocoded) | +| GET | `/current` | Get current weather for a user's saved location | + +## Architecture + +``` +Backend/ +├── Controller/ # REST API controllers +├── ServiceHandler/ # Business logic (market data, news, alerts, weather) +├── DatabaseHandler/ # PostgreSQL data access via Npgsql (no ORM) +├── Interface/ # Store and service abstractions +├── Middleware/ # API key auth, global exception filter +├── MarketDataRequest/ # IB Gateway integration and request/response models +└── AIModels/finbert/ # FinBERT ONNX model and tokenizer files +``` + +## Background Services + +- **AlertEvaluationService** — polls active alerts every minute during market hours and evaluates them against live prices +- **NewsSentimentRefreshService** — periodically refreshes news sentiment data for tracked interests + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `Microsoft.ML.OnnxRuntime` | FinBERT model inference | +| `Npgsql` | PostgreSQL driver | +| `Swashbuckle.AspNetCore` | Swagger / OpenAPI | +| `twsapi` (project ref) | Interactive Brokers TWS API client | diff --git a/ServiceHandler/AlertEvaluationService.cs b/ServiceHandler/AlertEvaluationService.cs index 1900a40..14362bc 100644 --- a/ServiceHandler/AlertEvaluationService.cs +++ b/ServiceHandler/AlertEvaluationService.cs @@ -1,6 +1,6 @@ using Backend.Interface; -namespace Backend.ServiceHander; +namespace Backend.ServiceHandler; public sealed class AlertEvaluationService : BackgroundService { diff --git a/ServiceHandler/EdgarFilingService.cs b/ServiceHandler/EdgarFilingService.cs index 633d300..0cdab5e 100644 --- a/ServiceHandler/EdgarFilingService.cs +++ b/ServiceHandler/EdgarFilingService.cs @@ -5,7 +5,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { public sealed class EdgarFilingService : IDisposable { diff --git a/ServiceHandler/FinBertScoringService.cs b/ServiceHandler/FinBertScoringService.cs index 67e745e..3637045 100644 --- a/ServiceHandler/FinBertScoringService.cs +++ b/ServiceHandler/FinBertScoringService.cs @@ -3,7 +3,7 @@ using Microsoft.ML.OnnxRuntime.Tensors; using System.Globalization; using System.Text; -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { public sealed class FinBertScoringService : IDisposable { diff --git a/ServiceHandler/MarketDataHander.cs b/ServiceHandler/MarketDataHander.cs index 3209808..f2ec757 100644 --- a/ServiceHandler/MarketDataHander.cs +++ b/ServiceHandler/MarketDataHander.cs @@ -4,7 +4,7 @@ using Backend.Interface; using IBApi; using System.Globalization; -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { public sealed class MarketDataService { diff --git a/ServiceHandler/NewsSentimentRefreshService.cs b/ServiceHandler/NewsSentimentRefreshService.cs index e1f31bd..8f33e24 100644 --- a/ServiceHandler/NewsSentimentRefreshService.cs +++ b/ServiceHandler/NewsSentimentRefreshService.cs @@ -1,6 +1,6 @@ using Backend.Interface; -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { public sealed class NewsSentimentRefreshService : BackgroundService { diff --git a/ServiceHandler/NewsSentimentService.cs b/ServiceHandler/NewsSentimentService.cs index 3da86c4..ca6af73 100644 --- a/ServiceHandler/NewsSentimentService.cs +++ b/ServiceHandler/NewsSentimentService.cs @@ -4,7 +4,7 @@ using System.Security.Cryptography; using System.Text; using System.Xml.Linq; -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { public sealed class NewsSentimentService { diff --git a/ServiceHandler/PercentChangeEvaluator.cs b/ServiceHandler/PercentChangeEvaluator.cs index 2ced43f..ebe033b 100644 --- a/ServiceHandler/PercentChangeEvaluator.cs +++ b/ServiceHandler/PercentChangeEvaluator.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Backend.DatabaseHandler; using Backend.Interface; -namespace Backend.ServiceHander; +namespace Backend.ServiceHandler; public sealed class PercentChangeEvaluator : IAlertEvaluator { diff --git a/ServiceHandler/PriceSpikeEvaluator.cs b/ServiceHandler/PriceSpikeEvaluator.cs index 0e38714..934b72b 100644 --- a/ServiceHandler/PriceSpikeEvaluator.cs +++ b/ServiceHandler/PriceSpikeEvaluator.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Backend.DatabaseHandler; using Backend.Interface; -namespace Backend.ServiceHander; +namespace Backend.ServiceHandler; public sealed class PriceSpikeEvaluator : IAlertEvaluator { diff --git a/ServiceHandler/PriceThresholdEvaluator.cs b/ServiceHandler/PriceThresholdEvaluator.cs index 98c56be..ebb6465 100644 --- a/ServiceHandler/PriceThresholdEvaluator.cs +++ b/ServiceHandler/PriceThresholdEvaluator.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Backend.DatabaseHandler; using Backend.Interface; -namespace Backend.ServiceHander; +namespace Backend.ServiceHandler; public sealed class PriceThresholdEvaluator : IAlertEvaluator { diff --git a/ServiceHandler/TrailingPriceEvaluator.cs b/ServiceHandler/TrailingPriceEvaluator.cs index fd07c4d..325db39 100644 --- a/ServiceHandler/TrailingPriceEvaluator.cs +++ b/ServiceHandler/TrailingPriceEvaluator.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Backend.DatabaseHandler; using Backend.Interface; -namespace Backend.ServiceHander; +namespace Backend.ServiceHandler; public sealed class TrailingPriceEvaluator : IAlertEvaluator { diff --git a/ServiceHandler/UserInterestsHandler.cs b/ServiceHandler/UserInterestsHandler.cs index 95d40c9..c9278ac 100644 --- a/ServiceHandler/UserInterestsHandler.cs +++ b/ServiceHandler/UserInterestsHandler.cs @@ -1,18 +1,5 @@ -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { - public sealed class UserInterestFetchRequest - { - public string User { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; // "symbol" or "topic" - } - - public sealed class UserInterestModifyRequest - { - public string User { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; // "symbol" or "topic" - public string Value { get; set; } = string.Empty; // symbol or topic - } - public sealed class UserInterestFetchResponse { public List Items { get; set; } = new(); @@ -22,4 +9,4 @@ namespace Backend.ServiceHander { public bool Success { get; set; } } -} \ No newline at end of file +} diff --git a/ServiceHandler/WeatherService.cs b/ServiceHandler/WeatherService.cs index 7942d9b..4e284a9 100644 --- a/ServiceHandler/WeatherService.cs +++ b/ServiceHandler/WeatherService.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Backend.ServiceHander +namespace Backend.ServiceHandler { public sealed class WeatherService { @@ -85,4 +85,25 @@ namespace Backend.ServiceHander _ => "Unknown" }; } + + public sealed class CurrentWeatherResponse + { + public string City { get; init; } = string.Empty; + public double TemperatureF { get; init; } + public double HumidityPercent { get; init; } + public double WindSpeedMph { get; init; } + public int WeatherCode { get; init; } + public string Description { get; init; } = string.Empty; + + public static CurrentWeatherResponse From( + Backend.DatabaseHandler.UserLocationRecord location, WeatherResult weather) => new() + { + City = location.City, + TemperatureF = weather.TemperatureF, + HumidityPercent = weather.HumidityPercent, + WindSpeedMph = weather.WindSpeedMph, + WeatherCode = weather.WeatherCode, + Description = weather.Description + }; + } } diff --git a/appsettings.Development.json b/appsettings.Development.json index d17f59e..af788d3 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -6,6 +6,6 @@ } }, "ConnectionStrings": { - "MarketDataDb": "Host=127.0.0.1;Port=5432;Database=softtraderbackend_pricedata;Username=postgres;Password=Database@Dongfeng" + "MarketDataDb": "Host=127.0.0.1;Port=5432;Database=softtraderbackend_pricedata;Username=postgres;Password=CHANGE_ME" } } \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index 1d93f7b..411d984 100644 --- a/appsettings.json +++ b/appsettings.json @@ -6,10 +6,10 @@ } }, "ConnectionStrings": { - "MarketDataDb": "Host=127.0.0.1;Port=5432;Database=softtraderbackend_pricedata;Username=postgres;Password=Database@Dongfeng" + "MarketDataDb": "Host=127.0.0.1;Port=5432;Database=softtraderbackend_pricedata;Username=postgres;Password=CHANGE_ME" }, "Edgar": { - "UserAgent": "D. Z. dzhan116@ucr.edu", + "UserAgent": "CHANGE_ME", "RequestDelayMs": 600 }, "AllowedHosts": "*"