feat: add Citations and WatchlistChat components, integrate with API
- Implemented Citations component to display citation data. - Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality. - Integrated API calls for watchlist management and chat interactions. - Updated index.tsx to include new components in the main application layout. - Added API client in lib/api.ts for structured API interactions. - Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components. - Introduced styles in tokens.css for consistent theming across the application. - Configured TypeScript and Vite for project setup and development.
This commit is contained in:
1 parent
0db2cf28b3
commit
8c184ccae1
111 files changed
+12349
-62
No files matched your search
@@ -0,0 +1,161 @@
|
||||
// Package alerts evaluates 6 detection rules (docs/PLAN.md §11) over stored
|
||||
// snapshots. Rules are pure functions over typed inputs so fixture tests can
|
||||
// prove each fires (or stays silent) deterministically.
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"flowsight/internal/model"
|
||||
)
|
||||
|
||||
// Rule IDs for the 6 v1 detectors.
|
||||
const (
|
||||
RuleAccumulation = "accumulation"
|
||||
RuleForeignRev = "foreign-reversal"
|
||||
RuleInsiderSpike = "insider-spike"
|
||||
RuleUnusualVolume = "unusual-volume"
|
||||
RuleRotation = "sector-rotation"
|
||||
RuleSuspension = "suspension-watch"
|
||||
)
|
||||
|
||||
// Finding is one rule hit with human text + evidence + real citations.
|
||||
type Finding struct {
|
||||
Rule string
|
||||
Ticker string
|
||||
Message string
|
||||
Context map[string]any
|
||||
Citations []model.Citation
|
||||
}
|
||||
|
||||
// Accumulation fires on >=3 brokers net-buy 5d + volume > 1.5x 20d avg.
|
||||
func Accumulation(ticker string, netByBroker map[string]float64, volMult float64) (Finding, bool) {
|
||||
n := 0
|
||||
sum := 0.0
|
||||
for _, v := range netByBroker {
|
||||
if v > 0 {
|
||||
n++
|
||||
sum += v
|
||||
}
|
||||
}
|
||||
if n >= 3 && volMult > 1.5 {
|
||||
return Finding{Rule: RuleAccumulation, Ticker: ticker,
|
||||
Message: ticker + " accumulation: " + itoa(n) + " brokers net-buy, volume spike",
|
||||
Context: map[string]any{"brokers": n, "net_sum": sum, "vol_mult": volMult}}, true
|
||||
}
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// ForeignReversal fires on 5d cumulative outflow then 1d inflow with the last
|
||||
// day magnitude > 2x trailing 5d daily average (or the mirror).
|
||||
func ForeignReversal(ticker string, last6 []float64) (Finding, bool) {
|
||||
if len(last6) < 6 {
|
||||
return Finding{}, false
|
||||
}
|
||||
prev5, last := last6[:5], last6[5]
|
||||
sum5 := 0.0
|
||||
absAvg := 0.0
|
||||
for _, v := range prev5 {
|
||||
sum5 += v
|
||||
absAvg += abs(v)
|
||||
}
|
||||
absAvg /= 5
|
||||
if absAvg == 0 {
|
||||
return Finding{}, false
|
||||
}
|
||||
if sum5 < 0 && last > 0 && last > 2*absAvg {
|
||||
return Finding{Rule: RuleForeignRev, Ticker: ticker,
|
||||
Message: ticker + " foreign reversal: outflow flipped to inflow",
|
||||
Context: map[string]any{"sum5": sum5, "last": last}}, true
|
||||
}
|
||||
if sum5 > 0 && last < 0 && -last > 2*absAvg {
|
||||
return Finding{Rule: RuleForeignRev, Ticker: ticker,
|
||||
Message: ticker + " foreign reversal: inflow flipped to outflow",
|
||||
Context: map[string]any{"sum5": sum5, "last": last}}, true
|
||||
}
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// InsiderSpike fires on director/major buy volume > 2x 30d avg, or >=3
|
||||
// distinct insiders buying in 7d.
|
||||
func InsiderSpike(ticker string, buyVol, avg30 float64, distinct7d int) (Finding, bool) {
|
||||
if avg30 > 0 && buyVol > 2*avg30 {
|
||||
return Finding{Rule: RuleInsiderSpike, Ticker: ticker,
|
||||
Message: ticker + " insider spike: buy volume above 2x 30d avg",
|
||||
Context: map[string]any{"volume": buyVol, "avg30": avg30, "mult": buyVol / avg30}}, true
|
||||
}
|
||||
if distinct7d >= 3 {
|
||||
return Finding{Rule: RuleInsiderSpike, Ticker: ticker,
|
||||
Message: ticker + " insider cluster: 3+ insiders buying in 7d",
|
||||
Context: map[string]any{"distinct": distinct7d}}, true
|
||||
}
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// UnusualVolume fires on >3x 20d avg when the date is not an earnings date.
|
||||
func UnusualVolume(ticker, date string, mult float64, isEarningsDate bool) (Finding, bool) {
|
||||
if mult > 3 && !isEarningsDate {
|
||||
return Finding{Rule: RuleUnusualVolume, Ticker: ticker,
|
||||
Message: ticker + " unusual volume on " + date,
|
||||
Context: map[string]any{"mult": mult, "date": date}}, true
|
||||
}
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// SectorRotation fires when a subsector net flow flips sign week-over-week.
|
||||
func SectorRotation(sector string, prev, cur float64) (Finding, bool) {
|
||||
if (prev < 0 && cur > 0) || (prev > 0 && cur < 0) {
|
||||
dir := "inflow"
|
||||
if cur < 0 {
|
||||
dir = "outflow"
|
||||
}
|
||||
return Finding{Rule: RuleRotation, Ticker: sector,
|
||||
Message: "rotation: " + sector + " flipped to " + dir,
|
||||
Context: map[string]any{"prev": prev, "cur": cur}}, true
|
||||
}
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
// SuspensionWatch fires on any new suspension notice for a watchlist ticker.
|
||||
func SuspensionWatch(ticker, date, reason string, onWatchlist bool) (Finding, bool) {
|
||||
if reason == "" && date == "" {
|
||||
return Finding{}, false
|
||||
}
|
||||
if onWatchlist {
|
||||
return Finding{Rule: RuleSuspension, Ticker: ticker,
|
||||
Message: ticker + " suspended: " + reason,
|
||||
Context: map[string]any{"date": date, "reason": reason}}, true
|
||||
}
|
||||
return Finding{}, false
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
s := ""
|
||||
for n > 0 {
|
||||
s = string(rune('0'+n%10)) + s
|
||||
n /= 10
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MatchRule reports whether a user rule JSON targets a finding (simple
|
||||
// substring match on rule id or ticker; empty rule matches all).
|
||||
func MatchRule(ruleJSON string, f Finding) bool {
|
||||
if ruleJSON == "" || ruleJSON == "{}" {
|
||||
return true
|
||||
}
|
||||
r := strings.ToLower(ruleJSON)
|
||||
return strings.Contains(r, strings.ToLower(f.Rule)) ||
|
||||
strings.Contains(r, strings.ToLower(f.Ticker)) ||
|
||||
strings.Contains(r, `"all"`)
|
||||
}
|
||||
Reference in new issue
Block a user