This commit is contained in:
2026-04-20 21:29:22 +02:00
parent 53dd49612d
commit 89fc0119f3
25 changed files with 3744 additions and 134 deletions
+141
View File
@@ -0,0 +1,141 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"git.rouggy.com/rouggy/stockradar/internal/scanner"
)
// ---- eToro ----
func (s *Server) handleSyncEtoro(w http.ResponseWriter, r *http.Request) {
go func() { s.etoroPoller.Sync() }()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"syncing"}`))
}
func (s *Server) handleEtoroStatus(w http.ResponseWriter, r *http.Request) {
status := s.etoroPoller.Status()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// ---- Discovery ----
func (s *Server) handleRunDiscovery(w http.ResponseWriter, r *http.Request) {
started := s.discovery.Run()
w.Header().Set("Content-Type", "application/json")
if started {
w.Write([]byte(`{"status":"started"}`))
} else {
w.Write([]byte(`{"status":"already_running"}`))
}
}
func (s *Server) handleDiscoveryStatus(w http.ResponseWriter, r *http.Request) {
status := s.discovery.Status()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (s *Server) handleGetDiscovery(w http.ResponseWriter, r *http.Request) {
minScore := r.URL.Query().Get("min_score")
if minScore == "" {
minScore = "30"
}
rows, err := s.db.Query(`
SELECT sig.ticker, COALESCE(inst.name, sig.ticker),
sig.price, sig.change_pct, sig.rsi14,
sig.macd_hist, sig.volume, sig.avg_volume20,
COALESCE(sig.week52_high, 0), COALESCE(sig.pct_from_high, 0),
COALESCE(sig.market_cap, 0),
COALESCE(sig.score, 0), COALESCE(sig.alert,''), sig.computed_at
FROM signals sig
LEFT JOIN instruments inst ON inst.ticker = sig.ticker
WHERE sig.source = 'discovery'
AND sig.on_etoro = 1
AND sig.score >= ?
ORDER BY sig.score DESC
LIMIT 200
`, minScore)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type discoveryRow struct {
Ticker string `json:"ticker"`
Name string `json:"name"`
Price float64 `json:"price"`
ChangePct float64 `json:"change_pct"`
RSI14 float64 `json:"rsi14"`
MACDHist float64 `json:"macd_hist"`
Volume int64 `json:"volume"`
AvgVolume20 int64 `json:"avg_volume20"`
Week52High float64 `json:"week52_high"`
PctFromHigh float64 `json:"pct_from_high"`
MarketCap int64 `json:"market_cap"`
Score int `json:"score"`
Alert string `json:"alert"`
ComputedAt string `json:"computed_at"`
}
results := []discoveryRow{}
for rows.Next() {
var row discoveryRow
var vol sql.NullInt64
var avg sql.NullInt64
if err := rows.Scan(
&row.Ticker, &row.Name,
&row.Price, &row.ChangePct, &row.RSI14,
&row.MACDHist, &vol, &avg,
&row.Week52High, &row.PctFromHigh,
&row.MarketCap,
&row.Score, &row.Alert, &row.ComputedAt,
); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if vol.Valid {
row.Volume = vol.Int64
}
if avg.Valid {
row.AvgVolume20 = avg.Int64
}
results = append(results, row)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
// handleEtoroStats garde la compatibilité avec l'ancien endpoint
func (s *Server) handleEtoroStats(w http.ResponseWriter, r *http.Request) {
s.handleEtoroStatus(w, r)
}
// Scan watchlist signal - déjà dans handlers_scanner.go, on ajoute juste
// un champ source à la query existante
func signalFromRow(rows interface {
Scan(...any) error
}) (scanner.Signal, int, error) {
var sig scanner.Signal
var onEtoro int
err := rows.Scan(
&sig.Ticker, &sig.Name,
&sig.Price, &sig.ChangePct,
&sig.RSI14, &sig.MACD, &sig.MACDSignal, &sig.MACDHist,
&sig.SMA20, &sig.SMA50, &sig.Volume, &sig.AvgVolume20,
&sig.MarketCap, &sig.ShortRatio,
&sig.Week52High, &sig.Week52Low,
&sig.PctFromHigh, &sig.InsiderValue30d,
&sig.Score, &onEtoro,
&sig.Alert, &sig.ComputedAt,
)
return sig, onEtoro, err
}
+66
View File
@@ -0,0 +1,66 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
)
type insiderTradeRow struct {
ID int `json:"id"`
Ticker string `json:"ticker"`
InsiderName string `json:"insider_name"`
InsiderTitle string `json:"insider_title"`
TransactionCode string `json:"transaction_code"`
Shares float64 `json:"shares"`
Price float64 `json:"price"`
TotalValue float64 `json:"total_value"`
TransactionDate string `json:"transaction_date"`
FilingURL string `json:"filing_url"`
}
func (s *Server) handleGetInsiderTrades(w http.ResponseWriter, r *http.Request) {
ticker := r.URL.Query().Get("ticker")
base := `
SELECT id, ticker, COALESCE(insider_name,''), COALESCE(insider_title,''),
COALESCE(transaction_code,''), COALESCE(shares,0), COALESCE(price,0),
COALESCE(total_value,0), COALESCE(transaction_date,''), COALESCE(filing_url,'')
FROM insider_trades`
var rows *sql.Rows
var err error
if ticker != "" {
rows, err = s.db.Query(base+` WHERE ticker = ? ORDER BY transaction_date DESC LIMIT 100`, ticker)
} else {
rows, err = s.db.Query(base + ` ORDER BY transaction_date DESC LIMIT 200`)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
trades := []insiderTradeRow{}
for rows.Next() {
var t insiderTradeRow
if err := rows.Scan(
&t.ID, &t.Ticker, &t.InsiderName, &t.InsiderTitle,
&t.TransactionCode, &t.Shares, &t.Price, &t.TotalValue,
&t.TransactionDate, &t.FilingURL,
); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
trades = append(trades, t)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(trades)
}
func (s *Server) handleSyncInsider(w http.ResponseWriter, r *http.Request) {
go func() { s.edgarPoller.Sync() }()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"syncing"}`))
}
+11
View File
@@ -6,6 +6,17 @@ import (
"net/http"
)
func (s *Server) handleNewsSync(w http.ResponseWriter, r *http.Request) {
go func() {
if err := s.poller.Sync(); err != nil {
// logged inside Sync()
_ = err
}
}()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"syncing"}`))
}
type newsItem struct {
ID int `json:"id"`
Ticker string `json:"ticker"`
+115
View File
@@ -0,0 +1,115 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"git.rouggy.com/rouggy/stockradar/internal/scanner"
)
func (s *Server) handleGetSignals(w http.ResponseWriter, r *http.Request) {
onlyEtoro := r.URL.Query().Get("etoro") == "1"
query := `
SELECT sig.ticker, COALESCE(inst.name, sig.ticker),
sig.price, sig.change_pct, sig.rsi14,
sig.macd, sig.macd_signal, sig.macd_hist,
sig.sma20, sig.sma50, sig.volume, sig.avg_volume20,
COALESCE(sig.market_cap, 0), COALESCE(sig.short_ratio, 0),
COALESCE(sig.week52_high, 0), COALESCE(sig.week52_low, 0),
COALESCE(sig.pct_from_high, 0), COALESCE(sig.insider_value_30d, 0),
COALESCE(sig.score, 0), COALESCE(sig.on_etoro, 0),
COALESCE(sig.alert,''), sig.computed_at
FROM signals sig
LEFT JOIN instruments inst ON inst.ticker = sig.ticker`
if onlyEtoro {
query += ` WHERE sig.on_etoro = 1`
}
query += ` ORDER BY sig.score DESC, CASE WHEN sig.alert != '' THEN 0 ELSE 1 END`
rows, err := s.db.Query(query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
signals := []scanner.Signal{}
for rows.Next() {
var sig scanner.Signal
var onEtoro int
if err := rows.Scan(
&sig.Ticker, &sig.Name,
&sig.Price, &sig.ChangePct,
&sig.RSI14, &sig.MACD, &sig.MACDSignal, &sig.MACDHist,
&sig.SMA20, &sig.SMA50, &sig.Volume, &sig.AvgVolume20,
&sig.MarketCap, &sig.ShortRatio,
&sig.Week52High, &sig.Week52Low,
&sig.PctFromHigh, &sig.InsiderValue30d,
&sig.Score, &onEtoro,
&sig.Alert, &sig.ComputedAt,
); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sig.OnEtoro = onEtoro == 1
signals = append(signals, sig)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(signals)
}
func (s *Server) handleTriggerScan(w http.ResponseWriter, r *http.Request) {
go func() { s.scanner.Scan() }()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"scanning"}`))
}
func (s *Server) handleGetPrices(w http.ResponseWriter, r *http.Request) {
ticker := r.URL.Query().Get("ticker")
if ticker == "" {
http.Error(w, "ticker required", http.StatusBadRequest)
return
}
rows, err := s.db.Query(`
SELECT date, open, high, low, close, volume
FROM prices WHERE ticker = ?
ORDER BY date ASC LIMIT 90
`, ticker)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type bar struct {
Date string `json:"date"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume int64 `json:"volume"`
}
bars := []bar{}
for rows.Next() {
var b bar
var vol sql.NullInt64
if err := rows.Scan(&b.Date, &b.Open, &b.High, &b.Low, &b.Close, &vol); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if vol.Valid {
b.Volume = vol.Int64
}
bars = append(bars, b)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bars)
}
+62 -6
View File
@@ -6,15 +6,24 @@ import (
"net/http"
"git.rouggy.com/rouggy/stockradar/internal/db"
"git.rouggy.com/rouggy/stockradar/internal/edgar"
"git.rouggy.com/rouggy/stockradar/internal/etoro"
"git.rouggy.com/rouggy/stockradar/internal/finnhub"
"git.rouggy.com/rouggy/stockradar/internal/scanner"
"git.rouggy.com/rouggy/stockradar/internal/settings"
"github.com/gorilla/mux"
)
type Server struct {
db *db.DB
port string
router *mux.Router
settings *settings.Settings
db *db.DB
port string
router *mux.Router
settings *settings.Settings
poller *finnhub.Poller
scanner *scanner.Scanner
discovery *scanner.DiscoveryScanner
edgarPoller *edgar.Poller
etoroPoller *etoro.Poller
}
func New(database *db.DB, port string) (*Server, error) {
@@ -29,6 +38,23 @@ func New(database *db.DB, port string) (*Server, error) {
router: mux.NewRouter(),
settings: svc,
}
s.poller = finnhub.NewPoller(database, func() (string, error) {
return svc.Get("finnhub_api_key")
})
s.poller.Start()
s.scanner = scanner.New(database)
s.scanner.Start()
s.discovery = scanner.NewDiscovery(database)
s.edgarPoller = edgar.NewPoller(database)
s.edgarPoller.Start()
s.etoroPoller = etoro.NewPoller(database)
s.etoroPoller.Start()
s.setupRoutes()
return s, nil
}
@@ -51,6 +77,25 @@ func (s *Server) setupRoutes() {
// News
api.HandleFunc("/news", s.handleGetNews).Methods("GET", "OPTIONS")
api.HandleFunc("/news/sync", s.handleNewsSync).Methods("POST", "OPTIONS")
// Scanner / Signals
api.HandleFunc("/signals", s.handleGetSignals).Methods("GET", "OPTIONS")
api.HandleFunc("/signals/scan", s.handleTriggerScan).Methods("POST", "OPTIONS")
api.HandleFunc("/prices", s.handleGetPrices).Methods("GET", "OPTIONS")
// Insider trades (SEC EDGAR)
api.HandleFunc("/insider-trades", s.handleGetInsiderTrades).Methods("GET", "OPTIONS")
api.HandleFunc("/insider-trades/sync", s.handleSyncInsider).Methods("POST", "OPTIONS")
// eToro universe
api.HandleFunc("/etoro/sync", s.handleSyncEtoro).Methods("POST", "OPTIONS")
api.HandleFunc("/etoro/status", s.handleEtoroStatus).Methods("GET", "OPTIONS")
// Discovery
api.HandleFunc("/discover", s.handleGetDiscovery).Methods("GET", "OPTIONS")
api.HandleFunc("/discover/run", s.handleRunDiscovery).Methods("POST", "OPTIONS")
api.HandleFunc("/discover/status", s.handleDiscoveryStatus).Methods("GET", "OPTIONS")
s.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "StockRadar API running")
@@ -137,8 +182,19 @@ func (s *Server) handleTestKey(w http.ResponseWriter, r *http.Request) {
return
}
// Pour l'instant on vérifie juste que la clé existe
// On branchera le vrai ping API plus tard
if provider == "finnhub" {
apiKey, err := s.settings.Get(keyName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := finnhub.New(apiKey).Ping(); err != nil {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"error","message":%q}`, err.Error())
return
}
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","provider":"%s"}`, provider)
}