Secured API with key.

This commit is contained in:
caspar 2026-03-21 09:16:59 -04:00
parent 6d76c60fc1
commit a955ab3b32
29 changed files with 454 additions and 315 deletions

View File

@ -1,11 +1,17 @@
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.ServiceHander; using Backend.ServiceHandler;
using Backend.Interface; using Backend.Interface;
using Backend.Middleware;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); 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<Backend.Middleware.GlobalExceptionFilter>();
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
@ -23,6 +29,8 @@ builder.Services.AddSingleton<WeatherService>();
builder.Services.AddSingleton<IAnnotationStore, PostgresAnnotationStore>(); builder.Services.AddSingleton<IAnnotationStore, PostgresAnnotationStore>();
builder.Services.AddSingleton<IApiKeyStore, PostgresApiKeyStore>();
builder.Services.AddSingleton<IAlertStore, PostgresAlertStore>(); builder.Services.AddSingleton<IAlertStore, PostgresAlertStore>();
builder.Services.AddSingleton<IAlertEvaluator, PriceThresholdEvaluator>(); builder.Services.AddSingleton<IAlertEvaluator, PriceThresholdEvaluator>();
builder.Services.AddSingleton<IAlertEvaluator, PercentChangeEvaluator>(); builder.Services.AddSingleton<IAlertEvaluator, PercentChangeEvaluator>();
@ -59,6 +67,7 @@ app.UseForwardedHeaders();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseCors("Frontend"); app.UseCors("Frontend");
app.UseMiddleware<ApiKeyMiddleware>();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@ -1,7 +1,7 @@
using Backend.Interface; using Backend.Interface;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Backend.Controller namespace Backend.Controllers
{ {
[ApiController] [ApiController]
[Route("api/annotations")] [Route("api/annotations")]
@ -19,8 +19,6 @@ namespace Backend.Controller
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string symbol, [FromQuery] string symbol,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
@ -35,19 +33,12 @@ namespace Backend.Controller
return Content(json, "application/json"); return Content(json, "application/json");
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpPost("save")] [HttpPost("save")]
public async Task<IActionResult> Save( public async Task<IActionResult> Save(
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string symbol, [FromQuery] string symbol,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
@ -65,19 +56,12 @@ namespace Backend.Controller
return Ok(new { success = true }); return Ok(new { success = true });
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("delete")] [HttpDelete("delete")]
public async Task<IActionResult> Delete( public async Task<IActionResult> Delete(
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string symbol, [FromQuery] string symbol,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
@ -89,10 +73,5 @@ namespace Backend.Controller
return Ok(new { success = true }); return Ok(new { success = true });
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
} }
} }

View File

@ -1,10 +1,10 @@
using Backend.ServiceHander; using Backend.ServiceHandler;
using Backend.MarketDataRequest; using Backend.MarketDataRequest;
using Backend.Interface; using Backend.Interface;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Globalization; using System.Globalization;
namespace Backend.Controller namespace Backend.Controllers
{ {
[ApiController] [ApiController]
[Route("api/market")] [Route("api/market")]
@ -23,8 +23,6 @@ namespace Backend.Controller
public async Task<IActionResult> GetLastTradingDay( public async Task<IActionResult> GetLastTradingDay(
[FromQuery] string symbol = "SPY", [FromQuery] string symbol = "SPY",
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
var lastDay = await _cache.GetLastTradingDayAsync( var lastDay = await _cache.GetLastTradingDayAsync(
symbol.Trim().ToUpperInvariant(), cancellationToken); symbol.Trim().ToUpperInvariant(), cancellationToken);
@ -38,46 +36,19 @@ namespace Backend.Controller
lastTradingDay = lastDay.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture) lastTradingDay = lastDay.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)
}); });
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("symbol-check/{symbol}")] [HttpGet("symbol-check/{symbol}")]
public IActionResult CheckSymbol(string symbol) public IActionResult CheckSymbol(string symbol)
{
try
{ {
var (found, message) = _marketDataService.CheckSymbol(symbol.ToUpperInvariant()); var (found, message) = _marketDataService.CheckSymbol(symbol.ToUpperInvariant());
return Ok(new { found, message }); return Ok(new { found, message });
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("live/{symbol}")] [HttpGet("live/{symbol}")]
public IActionResult GetLive(string symbol) public IActionResult GetLive(string symbol)
{
try
{ {
var data = _marketDataService.GetLiveQuote(symbol.ToUpperInvariant()); var data = _marketDataService.GetLiveQuote(symbol.ToUpperInvariant());
return Ok(new return Ok(data);
{
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() });
}
} }
[HttpGet("historical")] [HttpGet("historical")]
@ -88,8 +59,6 @@ namespace Backend.Controller
[FromQuery] HistoricalBarSize barSize, [FromQuery] HistoricalBarSize barSize,
[FromQuery] HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades, [FromQuery] HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades,
[FromQuery] bool useRth = true) [FromQuery] bool useRth = true)
{
try
{ {
var data = await _marketDataService.GetHistoricalAsync( var data = await _marketDataService.GetHistoricalAsync(
symbol.ToUpperInvariant(), symbol.ToUpperInvariant(),
@ -102,11 +71,6 @@ namespace Backend.Controller
return Ok(data); return Ok(data);
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("historical-line")] [HttpGet("historical-line")]
public async Task<IActionResult> GetHistoricalLine( public async Task<IActionResult> GetHistoricalLine(
@ -116,8 +80,6 @@ namespace Backend.Controller
[FromQuery] HistoricalBarSize barSize, [FromQuery] HistoricalBarSize barSize,
[FromQuery] HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades, [FromQuery] HistoricalWhatToShow whatToShow = HistoricalWhatToShow.Trades,
[FromQuery] bool useRth = true) [FromQuery] bool useRth = true)
{
try
{ {
var data = await _marketDataService.GetHistoricalLineAsync( var data = await _marketDataService.GetHistoricalLineAsync(
symbol.ToUpperInvariant(), symbol.ToUpperInvariant(),
@ -130,10 +92,5 @@ namespace Backend.Controller
return Ok(data); return Ok(data);
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
} }
} }

View File

@ -1,8 +1,9 @@
using Backend.DatabaseHandler;
using Backend.Interface; using Backend.Interface;
using Backend.ServiceHander; using Backend.ServiceHandler;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Backend.Controller namespace Backend.Controllers
{ {
[ApiController] [ApiController]
[Route("api/news-sentiment")] [Route("api/news-sentiment")]
@ -28,30 +29,9 @@ namespace Backend.Controller
public async Task<IActionResult> Analyze( public async Task<IActionResult> Analyze(
[FromQuery] string keyword, [FromQuery] string keyword,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{
try
{ {
var results = await _service.AnalyzeAsync(keyword, cancellationToken); var results = await _service.AnalyzeAsync(keyword, cancellationToken);
return Ok(results.Select(r => new return Ok(results);
{
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() });
}
} }
[HttpGet("feed")] [HttpGet("feed")]
@ -60,8 +40,6 @@ namespace Backend.Controller
[FromQuery] string? keyword = null, [FromQuery] string? keyword = null,
[FromQuery(Name = "request-amount")] int requestAmount = 20, [FromQuery(Name = "request-amount")] int requestAmount = 20,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
@ -87,24 +65,9 @@ namespace Backend.Controller
var results = await _store.GetFeedAsync(keywords, requestAmount, cancellationToken); var results = await _store.GetFeedAsync(keywords, requestAmount, cancellationToken);
return Ok(results.Select(r => new return Ok(results.Select(NewsFeedItem.From));
{
title = r.Title,
publisher = r.Publisher,
score = r.Score,
publishedAt = r.PublishedAt.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"),
url = r.Url
}));
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
} }
[HttpGet("refresh")] [HttpGet("refresh")]
public async Task<ActionResult<BoolResponse>> Refresh( public async Task<ActionResult<BoolResponse>> Refresh(
[FromQuery] string user, [FromQuery] string user,
@ -115,7 +78,6 @@ namespace Backend.Controller
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
// 5 second refresh cooldown - abuse protection
lock (_refreshLock) lock (_refreshLock)
{ {
if ((DateTime.UtcNow - _lastRefresh).TotalSeconds < 5) if ((DateTime.UtcNow - _lastRefresh).TotalSeconds < 5)
@ -125,11 +87,11 @@ namespace Backend.Controller
var symbols = await _interests.FetchAllAsync(user, "symbol"); var symbols = await _interests.FetchAllAsync(user, "symbol");
var topics = await _interests.FetchAllAsync(user, "topic"); 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 }); return Ok(new BoolResponse { Success = true });

View File

@ -1,5 +1,4 @@
using Backend.DatabaseHandler; using Backend.ServiceHandler;
using Backend.ServiceHander;
using Backend.Interface; using Backend.Interface;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -20,8 +19,6 @@ namespace Backend.Controllers
public async Task<ActionResult<UserInterestFetchResponse>> FetchAll( public async Task<ActionResult<UserInterestFetchResponse>> FetchAll(
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string type) [FromQuery] string type)
{
try
{ {
List<string> items = await _store.FetchAllAsync(user, type); List<string> items = await _store.FetchAllAsync(user, type);
return Ok(new UserInterestFetchResponse return Ok(new UserInterestFetchResponse
@ -29,44 +26,25 @@ namespace Backend.Controllers
Items = items Items = items
}); });
} }
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("add")] [HttpPost("add")]
public async Task<ActionResult<BoolResponse>> Add( public async Task<ActionResult<BoolResponse>> Add(
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string type, [FromQuery] string type,
[FromQuery] string value) [FromQuery] string value)
{
try
{ {
bool success = await _store.AddAsync(user, type, value); bool success = await _store.AddAsync(user, type, value);
return Ok(new BoolResponse { Success = success }); return Ok(new BoolResponse { Success = success });
} }
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("remove")] [HttpDelete("remove")]
public async Task<ActionResult<BoolResponse>> Remove( public async Task<ActionResult<BoolResponse>> Remove(
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string type, [FromQuery] string type,
[FromQuery] string value) [FromQuery] string value)
{
try
{ {
bool success = await _store.RemoveAsync(user, type, value); bool success = await _store.RemoveAsync(user, type, value);
return Ok(new BoolResponse { Success = success }); return Ok(new BoolResponse { Success = success });
} }
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
} }
} }

View File

@ -1,9 +1,9 @@
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.Interface; using Backend.Interface;
using Backend.ServiceHander; using Backend.ServiceHandler;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Backend.Controller namespace Backend.Controllers
{ {
[ApiController] [ApiController]
[Route("api/weather")] [Route("api/weather")]
@ -18,13 +18,11 @@ namespace Backend.Controller
_weather = weather; _weather = weather;
} }
[HttpGet("set-location")] [HttpPut("set-location")]
public async Task<IActionResult> SetLocation( public async Task<IActionResult> SetLocation(
[FromQuery] string user, [FromQuery] string user,
[FromQuery] string city, [FromQuery] string city,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
@ -47,18 +45,11 @@ namespace Backend.Controller
return Ok(new { city = resolvedCity, latitude = lat, longitude = lng }); return Ok(new { city = resolvedCity, latitude = lat, longitude = lng });
} }
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("current")] [HttpGet("current")]
public async Task<IActionResult> GetCurrent( public async Task<IActionResult> GetCurrent(
[FromQuery] string user, [FromQuery] string user,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
try
{ {
if (string.IsNullOrWhiteSpace(user)) if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty."); return BadRequest("User cannot be empty.");
@ -73,20 +64,7 @@ namespace Backend.Controller
if (weather is null) if (weather is null)
return StatusCode(500, new { error = "Failed to fetch weather data." }); return StatusCode(500, new { error = "Failed to fetch weather data." });
return Ok(new return Ok(CurrentWeatherResponse.From(location, weather));
{
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() });
}
} }
} }
} }

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Backend.DatabaseHandler namespace Backend.DatabaseHandler
{ {
public sealed class NewsSentimentRecord public sealed class NewsSentimentRecord
@ -8,8 +10,28 @@ namespace Backend.DatabaseHandler
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
public double Score { get; init; } public double Score { get; init; }
public string SentimentLabel { get; init; } = string.Empty; public string SentimentLabel { get; init; } = string.Empty;
[JsonIgnore]
public string TitleHash { get; init; } = string.Empty; public string TitleHash { get; init; } = string.Empty;
public string Publisher { get; init; } = string.Empty; public string Publisher { get; init; } = string.Empty;
public string Url { 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
};
}
} }

View File

@ -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<bool> 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;
}
}

View File

@ -1,5 +1,5 @@
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.ServiceHander; using Backend.ServiceHandler;
namespace Backend.Interface; namespace Backend.Interface;

View File

@ -0,0 +1,6 @@
namespace Backend.Interface;
public interface IApiKeyStore
{
Task<bool> ValidateAsync(string apiKey, CancellationToken cancellationToken = default);
}

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Backend.MarketDataRequest namespace Backend.MarketDataRequest
{ {
public sealed class LiveEquitySnapshot public sealed class LiveEquitySnapshot
@ -7,15 +9,19 @@ namespace Backend.MarketDataRequest
public double? LastPrice { get; set; } public double? LastPrice { get; set; }
public long? Volume { get; set; } public long? Volume { get; set; }
public double? PreviousClose { get; set; } public double? PreviousClose { get; set; }
[JsonPropertyName("openPrice")]
public double? Open { get; set; } public double? Open { get; set; }
public bool MarketOpen { get; set; } = true; public bool MarketOpen { get; set; } = true;
[JsonIgnore]
public bool IsComplete => public bool IsComplete =>
LastPrice.HasValue && LastPrice.HasValue &&
Volume.HasValue && Volume.HasValue &&
PreviousClose.HasValue && PreviousClose.HasValue &&
Open.HasValue; Open.HasValue;
[JsonIgnore]
public bool HasMinimumData => public bool HasMinimumData =>
LastPrice.HasValue && LastPrice.HasValue &&
PreviousClose.HasValue; PreviousClose.HasValue;

View File

@ -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);
}
}

View File

@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Backend.Middleware;
public sealed class GlobalExceptionFilter : IExceptionFilter
{
private readonly ILogger<GlobalExceptionFilter> _logger;
public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> 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;
}
}

View File

@ -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;

117
README.md
View File

@ -1,3 +1,116 @@
# SoftTraderBackend # SoftTrader Backend
The backend for a lite Bloomberg terminal. 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=<your-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 |

View File

@ -1,6 +1,6 @@
using Backend.Interface; using Backend.Interface;
namespace Backend.ServiceHander; namespace Backend.ServiceHandler;
public sealed class AlertEvaluationService : BackgroundService public sealed class AlertEvaluationService : BackgroundService
{ {

View File

@ -5,7 +5,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Backend.ServiceHander namespace Backend.ServiceHandler
{ {
public sealed class EdgarFilingService : IDisposable public sealed class EdgarFilingService : IDisposable
{ {

View File

@ -3,7 +3,7 @@ using Microsoft.ML.OnnxRuntime.Tensors;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace Backend.ServiceHander namespace Backend.ServiceHandler
{ {
public sealed class FinBertScoringService : IDisposable public sealed class FinBertScoringService : IDisposable
{ {

View File

@ -4,7 +4,7 @@ using Backend.Interface;
using IBApi; using IBApi;
using System.Globalization; using System.Globalization;
namespace Backend.ServiceHander namespace Backend.ServiceHandler
{ {
public sealed class MarketDataService public sealed class MarketDataService
{ {

View File

@ -1,6 +1,6 @@
using Backend.Interface; using Backend.Interface;
namespace Backend.ServiceHander namespace Backend.ServiceHandler
{ {
public sealed class NewsSentimentRefreshService : BackgroundService public sealed class NewsSentimentRefreshService : BackgroundService
{ {

View File

@ -4,7 +4,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Xml.Linq; using System.Xml.Linq;
namespace Backend.ServiceHander namespace Backend.ServiceHandler
{ {
public sealed class NewsSentimentService public sealed class NewsSentimentService
{ {

View File

@ -2,7 +2,7 @@ using System.Text.Json;
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.Interface; using Backend.Interface;
namespace Backend.ServiceHander; namespace Backend.ServiceHandler;
public sealed class PercentChangeEvaluator : IAlertEvaluator public sealed class PercentChangeEvaluator : IAlertEvaluator
{ {

View File

@ -2,7 +2,7 @@ using System.Text.Json;
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.Interface; using Backend.Interface;
namespace Backend.ServiceHander; namespace Backend.ServiceHandler;
public sealed class PriceSpikeEvaluator : IAlertEvaluator public sealed class PriceSpikeEvaluator : IAlertEvaluator
{ {

View File

@ -2,7 +2,7 @@ using System.Text.Json;
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.Interface; using Backend.Interface;
namespace Backend.ServiceHander; namespace Backend.ServiceHandler;
public sealed class PriceThresholdEvaluator : IAlertEvaluator public sealed class PriceThresholdEvaluator : IAlertEvaluator
{ {

View File

@ -2,7 +2,7 @@ using System.Text.Json;
using Backend.DatabaseHandler; using Backend.DatabaseHandler;
using Backend.Interface; using Backend.Interface;
namespace Backend.ServiceHander; namespace Backend.ServiceHandler;
public sealed class TrailingPriceEvaluator : IAlertEvaluator public sealed class TrailingPriceEvaluator : IAlertEvaluator
{ {

View File

@ -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 sealed class UserInterestFetchResponse
{ {
public List<string> Items { get; set; } = new(); public List<string> Items { get; set; } = new();

View File

@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
namespace Backend.ServiceHander namespace Backend.ServiceHandler
{ {
public sealed class WeatherService public sealed class WeatherService
{ {
@ -85,4 +85,25 @@ namespace Backend.ServiceHander
_ => "Unknown" _ => "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
};
}
} }

View File

@ -6,6 +6,6 @@
} }
}, },
"ConnectionStrings": { "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"
} }
} }

View File

@ -6,10 +6,10 @@
} }
}, },
"ConnectionStrings": { "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": { "Edgar": {
"UserAgent": "D. Z. dzhan116@ucr.edu", "UserAgent": "CHANGE_ME",
"RequestDelayMs": 600 "RequestDelayMs": 600
}, },
"AllowedHosts": "*" "AllowedHosts": "*"