Refactored handler for alerts.

This commit is contained in:
caspar 2026-03-20 12:38:18 -04:00
parent 0ce5225434
commit 6d76c60fc1
7 changed files with 335 additions and 1 deletions

View File

@ -15,7 +15,7 @@ builder.Services.AddSingleton<IUserInterestStore, PostgresUserInterestStore>();
builder.Services.AddSingleton<FinBertScoringService>(); builder.Services.AddSingleton<FinBertScoringService>();
builder.Services.AddSingleton<INewsSentimentStore, PostgresNewsSentimentStore>(); builder.Services.AddSingleton<INewsSentimentStore, PostgresNewsSentimentStore>();
builder.Services.AddSingleton<NewsSentimentService>(); builder.Services.AddSingleton<NewsSentimentService>();
builder.Services.AddSingleton<EdgarFilingService>(); //builder.Services.AddSingleton<EdgarFilingService>();
builder.Services.AddHostedService<NewsSentimentRefreshService>(); builder.Services.AddHostedService<NewsSentimentRefreshService>();
builder.Services.AddSingleton<IUserLocationStore, PostgresUserLocationStore>(); builder.Services.AddSingleton<IUserLocationStore, PostgresUserLocationStore>();
@ -25,6 +25,9 @@ builder.Services.AddSingleton<IAnnotationStore, PostgresAnnotationStore>();
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, PriceSpikeEvaluator>();
builder.Services.AddSingleton<IAlertEvaluator, TrailingPriceEvaluator>();
builder.Services.AddHostedService<AlertEvaluationService>(); builder.Services.AddHostedService<AlertEvaluationService>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>

View File

@ -84,6 +84,10 @@ Console.WriteLine(raw);
HistoricalBarSize.FiveMins => 300, HistoricalBarSize.FiveMins => 300,
HistoricalBarSize.FifteenMins => 900, HistoricalBarSize.FifteenMins => 900,
HistoricalBarSize.OneHour => 3600, HistoricalBarSize.OneHour => 3600,
HistoricalBarSize.TwoHours => 7200,
HistoricalBarSize.ThreeHours => 10800,
HistoricalBarSize.FourHours => 14400,
HistoricalBarSize.EightHours => 28800,
HistoricalBarSize.OneDay => 86400, HistoricalBarSize.OneDay => 86400,
HistoricalBarSize.FiveDays => 432000, HistoricalBarSize.FiveDays => 432000,
HistoricalBarSize.OneWeek => 604800, HistoricalBarSize.OneWeek => 604800,

View File

@ -6,6 +6,10 @@ namespace Backend.MarketDataRequest
FiveMins, FiveMins,
FifteenMins, FifteenMins,
OneHour, OneHour,
TwoHours,
ThreeHours,
FourHours,
EightHours,
OneDay, OneDay,
FiveDays, FiveDays,
OneWeek, OneWeek,
@ -70,6 +74,10 @@ namespace Backend.MarketDataRequest
HistoricalBarSize.FiveMins => "5 mins", HistoricalBarSize.FiveMins => "5 mins",
HistoricalBarSize.FifteenMins => "15 mins", HistoricalBarSize.FifteenMins => "15 mins",
HistoricalBarSize.OneHour => "1 hour", HistoricalBarSize.OneHour => "1 hour",
HistoricalBarSize.TwoHours => "2 hours",
HistoricalBarSize.ThreeHours => "3 hours",
HistoricalBarSize.FourHours => "4 hours",
HistoricalBarSize.EightHours => "8 hours",
HistoricalBarSize.OneDay => "1 day", HistoricalBarSize.OneDay => "1 day",
HistoricalBarSize.FiveDays => "5 days", HistoricalBarSize.FiveDays => "5 days",
HistoricalBarSize.OneWeek => "1 week", HistoricalBarSize.OneWeek => "1 week",

View File

@ -0,0 +1,44 @@
using System.Text.Json;
using Backend.DatabaseHandler;
using Backend.Interface;
namespace Backend.ServiceHander;
public sealed class PercentChangeEvaluator : IAlertEvaluator
{
public string AlertType => "percent_change";
public Task<AlertEvaluation> EvaluateAsync(AlertRecord alert, MarketDataService marketData)
{
var parameters = JsonSerializer.Deserialize<PercentChangeParameters>(alert.Parameters)
?? throw new InvalidOperationException($"Invalid parameters for alert {alert.Id}");
var snapshot = marketData.GetLiveQuote(alert.Symbol);
if (!snapshot.LastPrice.HasValue || !snapshot.PreviousClose.HasValue || snapshot.PreviousClose.Value == 0)
{
return Task.FromResult(new AlertEvaluation { Triggered = false });
}
double price = snapshot.LastPrice.Value;
double prevClose = snapshot.PreviousClose.Value;
double percentChange = (price - prevClose) / prevClose * 100.0;
bool triggered = parameters.PercentThreshold >= 0
? percentChange >= parameters.PercentThreshold
: percentChange <= parameters.PercentThreshold;
string updatedState = JsonSerializer.Serialize(new { lastPrice = price, percentChange });
return Task.FromResult(new AlertEvaluation
{
Triggered = triggered,
UpdatedLastState = updatedState
});
}
private sealed class PercentChangeParameters
{
public double PercentThreshold { get; set; }
}
}

View File

@ -0,0 +1,71 @@
using System.Text.Json;
using Backend.DatabaseHandler;
using Backend.Interface;
namespace Backend.ServiceHander;
public sealed class PriceSpikeEvaluator : IAlertEvaluator
{
public string AlertType => "price_spike";
public Task<AlertEvaluation> EvaluateAsync(AlertRecord alert, MarketDataService marketData)
{
var parameters = JsonSerializer.Deserialize<PriceSpikeParameters>(alert.Parameters)
?? throw new InvalidOperationException($"Invalid parameters for alert {alert.Id}");
var snapshot = marketData.GetLiveQuote(alert.Symbol);
if (!snapshot.LastPrice.HasValue)
{
return Task.FromResult(new AlertEvaluation { Triggered = false });
}
double price = snapshot.LastPrice.Value;
var now = DateTime.UtcNow;
var state = JsonSerializer.Deserialize<PriceSpikeState>(alert.LastState)
?? new PriceSpikeState();
state.Prices ??= new List<PricePoint>();
state.Prices.Add(new PricePoint { Price = price, Timestamp = now });
var cutoff = now.AddMinutes(-parameters.WindowMinutes);
state.Prices.RemoveAll(p => p.Timestamp < cutoff);
bool triggered = false;
if (state.Prices.Count >= 2)
{
double oldest = state.Prices[0].Price;
if (oldest != 0)
{
double change = Math.Abs((price - oldest) / oldest * 100.0);
triggered = change >= parameters.PercentChange;
}
}
string updatedState = JsonSerializer.Serialize(state);
return Task.FromResult(new AlertEvaluation
{
Triggered = triggered,
UpdatedLastState = updatedState
});
}
private sealed class PriceSpikeParameters
{
public double PercentChange { get; set; }
public int WindowMinutes { get; set; } = 15;
}
private sealed class PriceSpikeState
{
public List<PricePoint>? Prices { get; set; }
}
private sealed class PricePoint
{
public double Price { get; set; }
public DateTime Timestamp { get; set; }
}
}

View File

@ -0,0 +1,73 @@
using System.Text.Json;
using Backend.DatabaseHandler;
using Backend.Interface;
namespace Backend.ServiceHander;
public sealed class TrailingPriceEvaluator : IAlertEvaluator
{
public string AlertType => "trailing_price";
public Task<AlertEvaluation> EvaluateAsync(AlertRecord alert, MarketDataService marketData)
{
var parameters = JsonSerializer.Deserialize<TrailingPriceParameters>(alert.Parameters)
?? throw new InvalidOperationException($"Invalid parameters for alert {alert.Id}");
var snapshot = marketData.GetLiveQuote(alert.Symbol);
if (!snapshot.LastPrice.HasValue)
{
return Task.FromResult(new AlertEvaluation { Triggered = false });
}
double price = snapshot.LastPrice.Value;
var state = JsonSerializer.Deserialize<TrailingPriceState>(alert.LastState)
?? new TrailingPriceState();
bool triggered;
if (parameters.Direction.Equals("bullish", StringComparison.OrdinalIgnoreCase))
{
if (!state.PeakPrice.HasValue || price > state.PeakPrice.Value)
state.PeakPrice = price;
double dropPercent = (state.PeakPrice.Value - price) / state.PeakPrice.Value * 100.0;
triggered = dropPercent >= parameters.TrailPercent;
}
else
{
if (!state.TroughPrice.HasValue || price < state.TroughPrice.Value)
state.TroughPrice = price;
double risePercent = state.TroughPrice.Value == 0
? 0
: (price - state.TroughPrice.Value) / state.TroughPrice.Value * 100.0;
triggered = risePercent >= parameters.TrailPercent;
}
string updatedState = JsonSerializer.Serialize(new
{
peakPrice = state.PeakPrice,
troughPrice = state.TroughPrice,
lastPrice = price
});
return Task.FromResult(new AlertEvaluation
{
Triggered = triggered,
UpdatedLastState = updatedState
});
}
private sealed class TrailingPriceParameters
{
public string Direction { get; set; } = string.Empty;
public double TrailPercent { get; set; }
}
private sealed class TrailingPriceState
{
public double? PeakPrice { get; set; }
public double? TroughPrice { get; set; }
}
}

131
docs/alerts-api.md Normal file
View File

@ -0,0 +1,131 @@
# Alerts API
Base URL: `https://api.dongfeng-systems.org`
---
## Create Alert
**POST** `/api/alerts/create`
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | string | yes | User identifier |
| `symbol` | string | yes | Ticker symbol (e.g. AAPL) |
| `alertType` | string | yes | One of the alert types below |
| `parameters` | string (JSON) | yes | JSON string with type-specific parameters |
### Alert Types & Parameters
**`price_threshold`** — Triggers when price crosses a target value.
```json
{ "direction": "above", "target": 200.0 }
```
| Param | Type | Description |
|---|---|---|
| `direction` | string | `"above"` or `"below"` |
| `target` | number | Target price |
**`percent_change`** — Triggers when daily percent change from previous close crosses a threshold.
```json
{ "percentThreshold": 5.0 }
```
| Param | Type | Description |
|---|---|---|
| `percentThreshold` | number | Positive triggers on gains, negative on losses |
**`price_spike`** — Triggers when price moves sharply within a rolling time window.
```json
{ "percentChange": 3.0, "windowMinutes": 10 }
```
| Param | Type | Description |
|---|---|---|
| `percentChange` | number | Minimum percent move to trigger |
| `windowMinutes` | number | Rolling window size in minutes (default: 15) |
**`trailing_price`** — Tracks a price extreme and triggers on reversal.
```json
{ "direction": "bullish", "trailPercent": 5.0 }
```
| Param | Type | Description |
|---|---|---|
| `direction` | string | `"bullish"` (triggers on drop from peak) or `"bearish"` (triggers on rise from trough) |
| `trailPercent` | number | Percent reversal from extreme to trigger |
### Example
```bash
curl -X POST https://api.dongfeng-systems.org/api/alerts/create \
-H "Content-Type: application/json" \
-d '{
"username": "joe",
"symbol": "AAPL",
"alertType": "price_threshold",
"parameters": "{\"direction\": \"above\", \"target\": 200.0}"
}'
```
**Response:**
```json
{
"id": 1,
"username": "joe",
"symbol": "AAPL",
"alertType": "price_threshold",
"parameters": "{\"direction\": \"above\", \"target\": 200.0}",
"lastState": "{}",
"isTriggered": false,
"createdAt": "2026-03-20T12:00:00Z"
}
```
---
## List Alerts
**GET** `/api/alerts/list?username={username}`
Returns all alerts for a user (both active and triggered).
```bash
curl "https://api.dongfeng-systems.org/api/alerts/list?username=joe"
```
---
## List Triggered Alerts
**GET** `/api/alerts/triggered?username={username}`
Returns only triggered alerts for a user.
```bash
curl "https://api.dongfeng-systems.org/api/alerts/triggered?username=joe"
```
---
## Delete Alert
**DELETE** `/api/alerts/delete?username={username}&id={alertId}`
Deletes an alert. Only the owning user can delete it.
```bash
curl -X DELETE "https://api.dongfeng-systems.org/api/alerts/delete?username=joe&id=1"
```
**Response:**
```json
{ "success": true }
```