Integrated news headline functions.

This commit is contained in:
caspar 2026-03-19 15:55:48 -04:00
parent 7f8d6a8131
commit f85779fde8
12 changed files with 355 additions and 83 deletions

View File

@ -15,6 +15,7 @@ builder.Services.AddSingleton<IUserInterestStore, PostgresUserInterestStore>();
builder.Services.AddSingleton<FinBertScoringService>();
builder.Services.AddSingleton<INewsSentimentStore, PostgresNewsSentimentStore>();
builder.Services.AddSingleton<NewsSentimentService>();
builder.Services.AddHostedService<NewsSentimentRefreshService>();
builder.Services.AddCors(options =>
{

View File

@ -1,3 +1,4 @@
using Backend.Interface;
using Backend.ServiceHander;
using Microsoft.AspNetCore.Mvc;
@ -7,11 +8,20 @@ namespace Backend.Controller
[Route("api/news-sentiment")]
public sealed class NewsSentimentController : ControllerBase
{
private static DateTime _lastRefresh = DateTime.MinValue;
private static readonly Lock _refreshLock = new();
private readonly NewsSentimentService _service;
private readonly IUserInterestStore _interests;
private readonly INewsSentimentStore _store;
public NewsSentimentController(NewsSentimentService service)
public NewsSentimentController(
NewsSentimentService service,
IUserInterestStore interests,
INewsSentimentStore store)
{
_service = service;
_interests = interests;
_store = store;
}
[HttpGet]
@ -30,6 +40,7 @@ namespace Backend.Controller
title = r.Title,
score = r.Score,
sentimentLabel = r.SentimentLabel,
publisher = r.Publisher,
url = r.Url
}));
}
@ -42,5 +53,91 @@ namespace Backend.Controller
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("feed")]
public async Task<IActionResult> Feed(
[FromQuery] string user,
[FromQuery] string? keyword = null,
[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))
{
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
}));
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.ToString() });
}
}
[HttpGet("refresh")]
public async Task<ActionResult<BoolResponse>> Refresh(
[FromQuery] string user,
CancellationToken cancellationToken = default)
{
try
{
if (string.IsNullOrWhiteSpace(user))
return BadRequest("User cannot be empty.");
// 5 second refresh cooldown - abuse protection
lock (_refreshLock)
{
if ((DateTime.UtcNow - _lastRefresh).TotalSeconds < 5)
return Ok(new BoolResponse { Success = false });
_lastRefresh = DateTime.UtcNow;
}
var symbols = await _interests.FetchAllAsync(user, "symbol");
var topics = await _interests.FetchAllAsync(user, "topic");
var keywords = symbols.Concat(topics).Distinct().ToList();
foreach (var keyword in keywords)
{
await _service.AnalyzeAsync(keyword, cancellationToken);
}
return Ok(new BoolResponse { Success = true });
}
catch (Exception)
{
return Ok(new BoolResponse { Success = false });
}
}
}
}

View File

@ -35,13 +35,15 @@ namespace Backend.Controllers
}
}
[HttpPost("add")]
[HttpGet("add")]
public async Task<ActionResult<BoolResponse>> Add(
[FromBody] UserInterestModifyRequest request)
[FromQuery] string user,
[FromQuery] string type,
[FromQuery] string value)
{
try
{
bool success = await _store.AddAsync(request.User, request.Type, request.Value);
bool success = await _store.AddAsync(user, type, value);
return Ok(new BoolResponse { Success = success });
}
catch (ArgumentException ex)
@ -50,13 +52,15 @@ namespace Backend.Controllers
}
}
[HttpDelete("remove")]
[HttpGet("remove")]
public async Task<ActionResult<BoolResponse>> Remove(
[FromBody] UserInterestModifyRequest request)
[FromQuery] string user,
[FromQuery] string type,
[FromQuery] string value)
{
try
{
bool success = await _store.RemoveAsync(request.User, request.Type, request.Value);
bool success = await _store.RemoveAsync(user, type, value);
return Ok(new BoolResponse { Success = success });
}
catch (ArgumentException ex)

View File

@ -94,11 +94,14 @@ Console.WriteLine(raw);
// Intraday fraction: (6.5h × 5 days) / (24h × 7 days) ≈ 0.194
// Daily/weekly fraction (weekdays only): 5 / 7 ≈ 0.714
// Monthly bars already map 1:1 to calendar months, no adjustment needed.
// NYSE has ~252 trading days per year (weekdays minus ~10 holidays).
// Using 252/365 instead of 5/7 avoids overestimating for 3+ month windows
// where accumulated holidays cause the cache check to fail.
double marketFraction = barSize switch
{
HistoricalBarSize.OneMonth => 1.0,
HistoricalBarSize.OneDay or HistoricalBarSize.FiveDays => 5.0 / 7.0,
_ => (6.5 * 5.0) / (24.0 * 7.0)
HistoricalBarSize.OneDay or HistoricalBarSize.FiveDays => 252.0 / 365.0,
_ => (6.5 * 252.0) / (24.0 * 365.0)
};
return Math.Max(1, (int)Math.Floor(duration.TotalSeconds * marketFraction / secondsPerBar));

View File

@ -9,6 +9,7 @@ namespace Backend.DatabaseHandler
public double Score { get; init; }
public string SentimentLabel { get; init; } = string.Empty;
public string TitleHash { get; init; } = string.Empty;
public string Publisher { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
}
}

View File

@ -24,7 +24,7 @@ namespace Backend.DatabaseHandler
const string sql = """
SELECT source, keyword, published_at, title, score,
sentiment_label, title_hash, url
sentiment_label, title_hash, publisher, url
FROM news_sentiment_cache
WHERE keyword = @keyword
ORDER BY published_at DESC;
@ -45,7 +45,55 @@ namespace Backend.DatabaseHandler
Score = reader.GetDouble(4),
SentimentLabel = reader.GetString(5),
TitleHash = reader.GetString(6).TrimEnd(),
Url = reader.GetString(7)
Publisher = reader.GetString(7),
Url = reader.GetString(8)
});
}
return results;
}
public async Task<List<NewsSentimentRecord>> GetFeedAsync(
IEnumerable<string> keywords,
int limit,
CancellationToken cancellationToken = default)
{
var keywordList = keywords.ToList();
if (keywordList.Count == 0)
return [];
var results = new List<NewsSentimentRecord>();
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
const string sql = """
SELECT source, keyword, published_at, title, score,
sentiment_label, title_hash, publisher, url
FROM news_sentiment_cache
WHERE keyword = ANY(@keywords)
ORDER BY published_at DESC
LIMIT @limit;
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("keywords", keywordList.ToArray());
cmd.Parameters.AddWithValue("limit", limit);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(new NewsSentimentRecord
{
Source = reader.GetString(0),
Keyword = reader.GetString(1),
PublishedAt = reader.GetFieldValue<DateTimeOffset>(2),
Title = reader.GetString(3),
Score = reader.GetDouble(4),
SentimentLabel = reader.GetString(5),
TitleHash = reader.GetString(6).TrimEnd(),
Publisher = reader.GetString(7),
Url = reader.GetString(8)
});
}
@ -68,10 +116,10 @@ namespace Backend.DatabaseHandler
const string sql = """
INSERT INTO news_sentiment_cache
(source, keyword, published_at, title, score,
sentiment_label, title_hash, url)
sentiment_label, title_hash, publisher, url)
VALUES
(@source, @keyword, @published_at, @title, @score,
@sentiment_label, @title_hash, @url)
@sentiment_label, @title_hash, @publisher, @url)
ON CONFLICT (title_hash) DO NOTHING;
""";
@ -85,6 +133,7 @@ namespace Backend.DatabaseHandler
cmd.Parameters.AddWithValue("score", record.Score);
cmd.Parameters.AddWithValue("sentiment_label", record.SentimentLabel);
cmd.Parameters.AddWithValue("title_hash", record.TitleHash);
cmd.Parameters.AddWithValue("publisher", record.Publisher);
cmd.Parameters.AddWithValue("url", record.Url);
await cmd.ExecuteNonQueryAsync(cancellationToken);

View File

@ -49,6 +49,29 @@ public sealed class PostgresUserInterestStore : IUserInterestStore
return results;
}
public async Task<List<string>> FetchAllDistinctValuesAsync(CancellationToken cancellationToken = default)
{
const string sql = """
SELECT symbol AS value FROM user_interest_symbols
UNION
SELECT topic AS value FROM user_interest_topics;
""";
var results = new List<string>();
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(reader.GetString(0));
}
return results;
}
public async Task<bool> AddAsync(string user, string type, string value)
{
ValidateUser(user);

View File

@ -8,6 +8,11 @@ namespace Backend.Interface
string keyword,
CancellationToken cancellationToken = default);
Task<List<NewsSentimentRecord>> GetFeedAsync(
IEnumerable<string> keywords,
int limit,
CancellationToken cancellationToken = default);
Task InsertAsync(
IEnumerable<NewsSentimentRecord> records,
CancellationToken cancellationToken = default);

View File

@ -3,6 +3,7 @@ namespace Backend.Interface
public interface IUserInterestStore
{
Task<List<string>> FetchAllAsync(string user, string type);
Task<List<string>> FetchAllDistinctValuesAsync(CancellationToken cancellationToken = default);
Task<bool> AddAsync(string user, string type, string value);
Task<bool> RemoveAsync(string user, string type, string value);
}

View File

@ -0,0 +1,78 @@
using Backend.Interface;
namespace Backend.ServiceHander
{
public sealed class NewsSentimentRefreshService : BackgroundService
{
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(15);
private static readonly TimeZoneInfo Pacific = TimeZoneInfo.FindSystemTimeZoneById("America/Los_Angeles");
private readonly IServiceProvider _services;
private readonly ILogger<NewsSentimentRefreshService> _logger;
public NewsSentimentRefreshService(
IServiceProvider services,
ILogger<NewsSentimentRefreshService> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (IsWithinActiveWindow())
{
await RefreshAsync(stoppingToken);
}
else
{
_logger.LogDebug("Outside active window (weekdays 07:00-20:00 PST), skipping refresh.");
}
await Task.Delay(PollInterval, stoppingToken);
}
}
private static bool IsWithinActiveWindow()
{
var pacificNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Pacific);
if (pacificNow.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
return false;
var hour = pacificNow.Hour;
return hour >= 7 && hour < 20;
}
private async Task RefreshAsync(CancellationToken cancellationToken)
{
try
{
var interests = _services.GetRequiredService<IUserInterestStore>();
var sentiment = _services.GetRequiredService<NewsSentimentService>();
var keywords = await interests.FetchAllDistinctValuesAsync(cancellationToken);
_logger.LogInformation("Refreshing news for {Count} keywords.", keywords.Count);
foreach (var keyword in keywords)
{
try
{
await sentiment.AnalyzeAsync(keyword, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh news for keyword '{Keyword}'.", keyword);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "News sentiment refresh cycle failed.");
}
}
}
}

View File

@ -40,17 +40,19 @@ namespace Backend.ServiceHander
if (existingHashes.Contains(titleHash))
continue;
var (score, label) = _scorer.Score(title);
var (headline, publisher) = ParsePublisher(title);
var (score, label) = _scorer.Score(headline);
newRecords.Add(new NewsSentimentRecord
{
Source = "google_rss",
Keyword = keyword,
PublishedAt = publishedAt,
Title = title,
Title = headline,
Score = score,
SentimentLabel = label,
TitleHash = titleHash,
Publisher = publisher,
Url = url
});
}
@ -92,6 +94,14 @@ namespace Backend.ServiceHander
return items;
}
private static (string Headline, string Publisher) ParsePublisher(string title)
{
int idx = title.LastIndexOf(" - ");
if (idx < 0)
return (title, "Unknown");
return (title[..idx].Trim(), title[(idx + 3)..].Trim());
}
private static string ComputeHash(string text)
{
byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));