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.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<Backend.Middleware.GlobalExceptionFilter>();
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@ -23,6 +29,8 @@ builder.Services.AddSingleton<WeatherService>();
builder.Services.AddSingleton<IAnnotationStore, PostgresAnnotationStore>();
builder.Services.AddSingleton<IApiKeyStore, PostgresApiKeyStore>();
builder.Services.AddSingleton<IAlertStore, PostgresAlertStore>();
builder.Services.AddSingleton<IAlertEvaluator, PriceThresholdEvaluator>();
builder.Services.AddSingleton<IAlertEvaluator, PercentChangeEvaluator>();
@ -59,6 +67,7 @@ app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseCors("Frontend");
app.UseMiddleware<ApiKeyMiddleware>();
app.MapControllers();
app.Run();

View File

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

View File

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

View File

@ -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<string> keywords;
if (!string.IsNullOrWhiteSpace(keyword))
{
if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty.");
if (requestAmount < 1)
requestAmount = 1;
List<string> 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<object>());
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<object>());
var results = await _store.GetFeedAsync(keywords, requestAmount, cancellationToken);
return Ok(results.Select(NewsFeedItem.From));
}
[HttpGet("refresh")]
public async Task<ActionResult<BoolResponse>> 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 });

View File

@ -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<string> items = await _store.FetchAllAsync(user, type);
return Ok(new UserInterestFetchResponse
{
List<string> 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<ActionResult<BoolResponse>> 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<ActionResult<BoolResponse>> 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 });
}
}
}

View File

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

View File

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

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.ServiceHander;
using Backend.ServiceHandler;
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
{
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;

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;
namespace Backend.ServiceHander;
namespace Backend.ServiceHandler;
public sealed class AlertEvaluationService : BackgroundService
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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"
}
}

View File

@ -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": "*"