Skip to main content

Business Logic & Rules (v1)

This document outlines the core domain logic governing the InventoryAlert.Api system.


1. Alert Evaluation Logic​

1.1 Price Alerts​

Evaluated during the price sync loop inside SyncPricesJob (cron-configured via WorkerSettings.Schedules.SyncPrices). Loads active AlertRule rows for each ticker and compares against the latest quote.

ConditionEvaluation
PriceAbovequote.CurrentPrice > rule.TargetValue
PriceBelowquote.CurrentPrice < rule.TargetValue
PriceTargetReached`

On breach:

  • An unread Notification row is written for the owning user.
  • rule.LastTriggeredAt is updated.
  • If rule.TriggerOnce = true, the rule is deactivated (IsActive = false).
  • A Redis cooldown key inventoryalert:alerts:cooldown:v1:{userId}:{ruleId} is set (24h TTL) to prevent alert storms.

1.2 Portfolio Cost-Basis Alerts (PercentDropFromCost)​

Evaluates an individual user's specific unrealized loss exposure.

  • Trigger: inside SyncPricesJob, if rule.Condition == PercentDropFromCost.
  • Formula: (avgCostBasis - currentPrice) / avgCostBasis * 100 >= rule.TargetValue
  • Cost Basis: Computed by joining Trade where UserId = rule.UserId AND TickerSymbol = rule.TickerSymbol, type = Buy.
  • User Isolation: This query is ALWAYS scoped to a single (UserId, TickerSymbol) pair. Never aggregated across users.

1.3 Low Holdings Alert (LowHoldingsCount)​

Triggered by LowHoldingsHandler via an integration event (inventoryalert.inventory.stock-low.v1) — not by the scheduled price sync cycle.

  • Formula: SUM(Quantity WHERE Type = Buy) - SUM(Quantity WHERE Type = Sell) < rule.TargetValue
  • Guard: The service guards against net holdings going negative (oversell) — rejects the trade with 422 Unprocessable.

2. Trade Audit Ledger​

Every holding change via POST/PATCH /portfolio is recorded as an immutable Trade row.

FieldDescription
UserIdOwner context — never cross-user
TickerSymbolMarket ticker
TypeBuy, Sell, Dividend, Split
QuantityAlways positive. Direction is encoded by Type.
UnitPriceCost per share at execution. 0 for Dividend/Split.
TradedAtUTC execution timestamp
NotesOptional annotation (max 500 chars)

Net holdings = SUM(Buy) - SUM(Sell) — computed dynamically by TradeRepository.GetNetHoldingsAsync.


3. Symbol Discovery (DB-First + Finnhub Fallback)​

Symbol resolution applies to every flow that requires a ticker: search, portfolio add, watchlist add, alert create.

Client request with symbol/query
↓
DB: SELECT FROM StockListing WHERE TickerSymbol = ? (exact) or ILIKE ? (search)
↓ Found → return immediately
↓ Not Found →
Finnhub: GET /search or /stock/profile2
↓ Not found → 404 Symbol not recognized
↓ Found →
DB: INSERT StockListing (ON CONFLICT DO NOTHING)
→ Return result to caller
→ Background: SyncMetricsJob enqueued for new symbol

Rule: Finnhub is called at most once per symbol. After that, all users benefit from the local cache permanently.


4. Portfolio Cascade Delete​

When DELETE /portfolio/positions/{symbol} is called:

StepAction
1. GuardIf user has active AlertRule for this symbol → return 409 Conflict. User must delete rules first.
2. CascadeDelete user's Trade rows for this symbol
3. CascadeDelete user's WatchlistItem for this symbol
4. PreserveKeep StockListing, PriceHistory, StockMetric, EarningsSurprise, RecommendationTrend, InsiderTransaction — these are global market data used by all users

5. Market Intelligence Sync Schedule​

Schedules are configurable via WorkerSettings.Schedules.*.

DataJobSchedule settingFinnhub Endpoint
Price quotesSyncPricesJobSchedules.SyncPrices/quote
Basic FinancialsSyncMetricsJobSchedules.SyncMetrics/stock/metric
Earnings SurprisesSyncEarningsJobSchedules.SyncEarnings/stock/earnings
Analyst RecommendationsSyncRecommendationsJobSchedules.SyncRecommendations/stock/recommendation
Insider TransactionsSyncInsidersJobSchedules.SyncInsiders/stock/insider-transactions
Market + Company NewsNewsSyncJobSchedules.MarketNews/news + /company-news
Price History CleanupCleanupPriceHistoryJobSchedules.CleanupPrices— (DB delete)

6. Validation Rules (FluentValidation)​

Applied at the Web layer only. The Application layer trusts pre-validated inputs.

DTOKey Rules
LoginRequestUsername NotEmpty MaxLength(50), Password MinLength(6) MaxLength(100)
RegisterRequestUsername Matches(^[a-zA-Z0-9_]+$), Password min 1 uppercase + 1 digit + 1 special char
CreatePositionRequestTickerSymbol Matches(^[A-Z0-9.]+$), Quantity > 0, UnitPrice > 0 && < 1_000_000, TradedAt <= UtcNow
TradeRequestType must be valid enum; Quantity > 0; UnitPrice > 0 (except Dividend/Split)
AlertRuleRequestPercentDropFromCost: TargetValue 0.01–100; LowHoldingsCount: whole number only