diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6ac593f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,30 @@
+# StockRadar
+
+## Contexte
+App de surveillance boursière pour trading personnel sur eToro.
+Stack : Go + Svelte, SQLite (modernc.org/sqlite), port 8082.
+
+## Architecture
+- cmd/stockradar/main.go → point d'entrée
+- internal/db → SQLite migrations
+- internal/settings → stockage clés API chiffrées AES-256
+- internal/server → HTTP + WebSocket (gorilla/mux)
+- frontend/ → Svelte (à créer)
+
+## Objectif
+Détecter des opportunités de trading avant les gros mouvements :
+- News catalyseurs (SEC EDGAR, GlobeNewswire, Finnhub)
+- Analyse technique (RSI, MACD, Volume)
+- Screener filtré sur univers eToro uniquement
+- Alertes push avant ouverture de marché
+
+## APIs
+- eToro API → instruments disponibles + portfolio
+- Yahoo Finance → historique + fondamentaux
+- Finnhub → news temps réel + earnings calendar
+- SEC EDGAR RSS → 8-K + Form 4 insider buying
+
+## Ports occupés
+- FlexDXCluster → 8080/8081
+- RentalManager → 9000
+- StockRadar → 8082
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..f12d5fb
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
Dashboard
+
+
+
+
Statut serveur
+
+ {health ? '● En ligne' : error ? '● Hors ligne' : '…'}
+
+
+
+
Port
+
{health?.port ?? '—'}
+
+
+
+
+
+
+
Activité récente
+
Aucune donnée pour l'instant — configure les clés API dans Settings.
+
+
+
+
diff --git a/frontend/src/routes/News.svelte b/frontend/src/routes/News.svelte
new file mode 100644
index 0000000..caf13a3
--- /dev/null
+++ b/frontend/src/routes/News.svelte
@@ -0,0 +1,148 @@
+
+
+
+
News
+
+
+ e.key === 'Enter' && load()}
+ />
+
+
+
+ {#if loading}
+
Chargement…
+ {:else if news.length === 0}
+
Aucune news disponible. Les données arriveront une fois les intégrations API branchées.
+ {:else}
+
+ {#each news as item}
+
+
+ {item.ticker ?? '—'}
+ {item.source ?? ''}
+ {timeAgo(item.published_at)}
+ {#if item.sentiment}
+ {item.sentiment}
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/Screener.svelte b/frontend/src/routes/Screener.svelte
new file mode 100644
index 0000000..6ad8c32
--- /dev/null
+++ b/frontend/src/routes/Screener.svelte
@@ -0,0 +1,89 @@
+
+
+
+
Screener
+
+
+
+
+
⊞
+
Le screener sera disponible après l'intégration Yahoo Finance / Finnhub.
+
Il filtrera l'univers eToro sur RSI, MACD, volume et catalyseurs news.
+
+
+
+
diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte
new file mode 100644
index 0000000..f895be8
--- /dev/null
+++ b/frontend/src/routes/Settings.svelte
@@ -0,0 +1,160 @@
+
+
+
+
+
diff --git a/frontend/src/routes/Watchlist.svelte b/frontend/src/routes/Watchlist.svelte
new file mode 100644
index 0000000..50175c6
--- /dev/null
+++ b/frontend/src/routes/Watchlist.svelte
@@ -0,0 +1,171 @@
+
+
+
+
Watchlist
+
+
+
+ {#if loading}
+
Chargement…
+ {:else if items.length === 0}
+
Aucun ticker dans la watchlist. Ajoutes-en un ci-dessus.
+ {:else}
+
+
+
+ | Ticker |
+ Nom |
+ Secteur |
+ Exchange |
+ |
+
+
+
+ {#each items as item}
+
+ | {item.ticker} |
+ {item.name ?? '—'} |
+ {item.sector ?? '—'} |
+ {item.exchange ?? '—'} |
+
+
+ |
+
+ {/each}
+
+
+ {/if}
+
+
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..716e945
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+
+export default defineConfig({
+ plugins: [svelte()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8082',
+ changeOrigin: true,
+ },
+ '/ws': {
+ target: 'ws://localhost:8082',
+ ws: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ },
+})
diff --git a/internal/server/handlers_news.go b/internal/server/handlers_news.go
new file mode 100644
index 0000000..f9281fb
--- /dev/null
+++ b/internal/server/handlers_news.go
@@ -0,0 +1,52 @@
+package server
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+)
+
+type newsItem struct {
+ ID int `json:"id"`
+ Ticker string `json:"ticker"`
+ Headline string `json:"headline"`
+ Source string `json:"source"`
+ URL string `json:"url"`
+ Sentiment string `json:"sentiment"`
+ PublishedAt string `json:"published_at"`
+}
+
+func (s *Server) handleGetNews(w http.ResponseWriter, r *http.Request) {
+ ticker := r.URL.Query().Get("ticker")
+
+ var rows *sql.Rows
+ var err error
+
+ if ticker != "" {
+ rows, err = s.db.Query(`
+ SELECT id, COALESCE(ticker,''), headline, COALESCE(source,''), COALESCE(url,''), COALESCE(sentiment,''), COALESCE(published_at,'')
+ FROM news WHERE ticker = ? ORDER BY published_at DESC LIMIT 100`, ticker)
+ } else {
+ rows, err = s.db.Query(`
+ SELECT id, COALESCE(ticker,''), headline, COALESCE(source,''), COALESCE(url,''), COALESCE(sentiment,''), COALESCE(published_at,'')
+ FROM news ORDER BY published_at DESC LIMIT 100`)
+ }
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ items := []newsItem{}
+ for rows.Next() {
+ var it newsItem
+ if err := rows.Scan(&it.ID, &it.Ticker, &it.Headline, &it.Source, &it.URL, &it.Sentiment, &it.PublishedAt); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ items = append(items, it)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(items)
+}
diff --git a/internal/server/handlers_watchlist.go b/internal/server/handlers_watchlist.go
new file mode 100644
index 0000000..35f924b
--- /dev/null
+++ b/internal/server/handlers_watchlist.go
@@ -0,0 +1,71 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+type watchlistItem struct {
+ ID int `json:"id"`
+ Ticker string `json:"ticker"`
+ Name string `json:"name"`
+ Sector string `json:"sector"`
+ Exchange string `json:"exchange"`
+ Active int `json:"active"`
+}
+
+func (s *Server) handleGetWatchlist(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.db.Query(`SELECT id, ticker, COALESCE(name,''), COALESCE(sector,''), COALESCE(exchange,''), active FROM watchlist WHERE active=1 ORDER BY ticker`)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ items := []watchlistItem{}
+ for rows.Next() {
+ var it watchlistItem
+ if err := rows.Scan(&it.ID, &it.Ticker, &it.Name, &it.Sector, &it.Exchange, &it.Active); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ items = append(items, it)
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(items)
+}
+
+func (s *Server) handleAddWatchlist(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Ticker string `json:"ticker"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Ticker == "" {
+ http.Error(w, "ticker required", http.StatusBadRequest)
+ return
+ }
+
+ _, err := s.db.Exec(`INSERT OR IGNORE INTO watchlist (ticker) VALUES (?)`, body.Ticker)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"status":"added"}`))
+}
+
+func (s *Server) handleRemoveWatchlist(w http.ResponseWriter, r *http.Request) {
+ ticker := mux.Vars(r)["ticker"]
+ _, err := s.db.Exec(`DELETE FROM watchlist WHERE ticker = ?`, ticker)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"status":"removed"}`))
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 23d4d9a..6e9ba23 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -34,19 +34,42 @@ func New(database *db.DB, port string) (*Server, error) {
}
func (s *Server) setupRoutes() {
+ s.router.Use(corsMiddleware)
+
api := s.router.PathPrefix("/api").Subrouter()
- api.HandleFunc("/health", s.handleHealth).Methods("GET")
+ api.HandleFunc("/health", s.handleHealth).Methods("GET", "OPTIONS")
// Settings
- api.HandleFunc("/settings", s.handleGetSettings).Methods("GET")
- api.HandleFunc("/settings", s.handleSaveSettings).Methods("POST")
- api.HandleFunc("/settings/test/{provider}", s.handleTestKey).Methods("GET")
+ api.HandleFunc("/settings", s.handleGetSettings).Methods("GET", "OPTIONS")
+ api.HandleFunc("/settings", s.handleSaveSettings).Methods("POST", "OPTIONS")
+ api.HandleFunc("/settings/test/{provider}", s.handleTestKey).Methods("GET", "OPTIONS")
+
+ // Watchlist
+ api.HandleFunc("/watchlist", s.handleGetWatchlist).Methods("GET", "OPTIONS")
+ api.HandleFunc("/watchlist", s.handleAddWatchlist).Methods("POST", "OPTIONS")
+ api.HandleFunc("/watchlist/{ticker}", s.handleRemoveWatchlist).Methods("DELETE", "OPTIONS")
+
+ // News
+ api.HandleFunc("/news", s.handleGetNews).Methods("GET", "OPTIONS")
s.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "StockRadar API running")
})
}
+func corsMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ if r.Method == "OPTIONS" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","port":"%s"}`, s.port)