Secured API with key.
This commit is contained in:
parent
6d76c60fc1
commit
a955ab3b32
13
Backend.cs
13
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<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();
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
33
DatabaseHandler/PostgresApiKeyStore.cs
Normal file
33
DatabaseHandler/PostgresApiKeyStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
using Backend.DatabaseHandler;
|
||||
using Backend.ServiceHander;
|
||||
using Backend.ServiceHandler;
|
||||
|
||||
namespace Backend.Interface;
|
||||
|
||||
|
||||
6
Interface/IApiKeyStore.cs
Normal file
6
Interface/IApiKeyStore.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Backend.Interface;
|
||||
|
||||
public interface IApiKeyStore
|
||||
{
|
||||
Task<bool> ValidateAsync(string apiKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
42
Middleware/ApiKeyMiddleware.cs
Normal file
42
Middleware/ApiKeyMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
34
Middleware/GlobalExceptionFilter.cs
Normal file
34
Middleware/GlobalExceptionFilter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
Migrations/001_create_api_keys.sql
Normal file
12
Migrations/001_create_api_keys.sql
Normal 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
117
README.md
@ -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 |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
using Backend.Interface;
|
||||
|
||||
namespace Backend.ServiceHander;
|
||||
namespace Backend.ServiceHandler;
|
||||
|
||||
public sealed class AlertEvaluationService : BackgroundService
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -4,7 +4,7 @@ using Backend.Interface;
|
||||
using IBApi;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Backend.ServiceHander
|
||||
namespace Backend.ServiceHandler
|
||||
{
|
||||
public sealed class MarketDataService
|
||||
{
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
using Backend.Interface;
|
||||
|
||||
namespace Backend.ServiceHander
|
||||
namespace Backend.ServiceHandler
|
||||
{
|
||||
public sealed class NewsSentimentRefreshService : BackgroundService
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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();
|
||||
@ -22,4 +9,4 @@ namespace Backend.ServiceHander
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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": "*"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user