Files
StockRadar/internal/edgar/client.go
T
2026-04-20 22:51:41 +02:00

395 lines
9.8 KiB
Go

package edgar
import (
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
const (
baseURL = "https://data.sec.gov"
archiveURL = "https://www.sec.gov/Archives/edgar/data"
userAgent = "StockRadar legreg002@hotmail.com"
)
type Client struct {
http *http.Client
cikMap map[string]string // ticker → CIK (zero-padded 10 digits)
cikOnce sync.Once
}
// InsiderTrade représente une transaction Form 4 parsée.
type InsiderTrade struct {
Ticker string
InsiderName string
InsiderTitle string
TransactionCode string // P=purchase, S=sale, A=award, etc.
Shares float64
PricePerShare float64
TotalValue float64
TransactionDate string
AccessionNo string
FilingURL string
}
// ---- types pour le parsing JSON/XML ----
type tickerEntry struct {
CIK int `json:"cik_str"`
Ticker string `json:"ticker"`
Title string `json:"title"`
}
type submissionsResponse struct {
Filings struct {
Recent struct {
Form []string `json:"form"`
AccessionNumber []string `json:"accessionNumber"`
FilingDate []string `json:"filingDate"`
PrimaryDocument []string `json:"primaryDocument"`
Items []string `json:"items"`
} `json:"recent"`
} `json:"filings"`
}
// CompanyEvent représente un événement 8-K significatif (ex: changement de direction).
type CompanyEvent struct {
Ticker string
EventType string // "ceo_change"
Title string
AccessionNo string
FilingDate string
FilingURL string
}
type form4Doc struct {
Issuer struct {
Symbol string `xml:"issuerTradingSymbol"`
} `xml:"issuer"`
ReportingOwner struct {
ID struct {
Name string `xml:"rptOwnerName"`
} `xml:"reportingOwnerId"`
Relationship struct {
IsDirector int `xml:"isDirector"`
IsOfficer int `xml:"isOfficer"`
Title string `xml:"officerTitle"`
} `xml:"reportingOwnerRelationship"`
} `xml:"reportingOwner"`
NonDerivativeTable struct {
Transactions []nonDerivativeTx `xml:"nonDerivativeTransaction"`
} `xml:"nonDerivativeTable"`
}
type nonDerivativeTx struct {
Date struct {
Value string `xml:"value"`
} `xml:"transactionDate"`
Coding struct {
Code string `xml:"transactionCode"`
} `xml:"transactionCoding"`
Amounts struct {
Shares struct {
Value float64 `xml:"value"`
} `xml:"transactionShares"`
Price struct {
Value float64 `xml:"value"`
} `xml:"transactionPricePerShare"`
AcqDisp struct {
Value string `xml:"value"`
} `xml:"transactionAcquiredDisposedCode"`
} `xml:"transactionAmounts"`
}
// ---- constructeur ----
func New() *Client {
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
}
}
// ---- API publique ----
// RecentInsiderBuys retourne les achats d'initiés (code P) pour un ticker
// sur les 30 derniers jours.
// isUSListed retourne false pour les tickers européens avec suffixe de bourse (.L, .PA, .DE…)
func isUSListed(ticker string) bool {
if idx := strings.LastIndex(ticker, "."); idx > 0 {
suffix := ticker[idx+1:]
// Suffixes US valides : A, B (BRK.A, BRK.B) → longueur 1 mais lettre unique
// Suffixes européens : L, PA, DE, AS, BR, HE, OL, ST, CO, MC, MI, VI…
if len(suffix) >= 2 {
return false // .PA, .DE, .AS etc. → non-US
}
}
return true
}
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
if !isUSListed(ticker) {
return nil, nil // silencieux pour les titres non-US
}
cik, err := c.lookupCIK(ticker)
if err != nil {
return nil, fmt.Errorf("CIK not found for %s: %w", ticker, err)
}
accessions, docs, dates, err := c.recentForm4Filings(cik, 30)
if err != nil {
return nil, err
}
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
var trades []InsiderTrade
for i, acc := range accessions {
if i >= len(dates) || dates[i] < cutoff {
continue
}
primaryDoc := ""
if i < len(docs) {
primaryDoc = docs[i]
}
form4Trades, err := c.parseForm4(cik, acc, primaryDoc, ticker)
if err != nil {
continue // on skip les erreurs de parsing individuelles
}
trades = append(trades, form4Trades...)
time.Sleep(120 * time.Millisecond) // EDGAR rate limit
}
return trades, nil
}
// Recent8KEvents retourne les 8-K Item 5.02 (changements de direction) des 30 derniers jours.
func (c *Client) Recent8KEvents(ticker string) ([]CompanyEvent, error) {
if !isUSListed(ticker) {
return nil, nil
}
cik, err := c.lookupCIK(ticker)
if err != nil {
return nil, nil // ticker inconnu → silencieux
}
url := fmt.Sprintf("%s/submissions/CIK%s.json", baseURL, cik)
resp, err := c.get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var sub submissionsResponse
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
return nil, err
}
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
forms := sub.Filings.Recent.Form
accs := sub.Filings.Recent.AccessionNumber
dates := sub.Filings.Recent.FilingDate
items := sub.Filings.Recent.Items
var events []CompanyEvent
for i, form := range forms {
if form != "8-K" && form != "8-K/A" {
continue
}
date := ""
if i < len(dates) {
date = dates[i]
}
if date != "" && date < cutoff {
break // filings triés du plus récent au plus ancien
}
itemStr := ""
if i < len(items) {
itemStr = items[i]
}
if !strings.Contains(itemStr, "5.02") {
continue
}
acc := ""
if i < len(accs) {
acc = accs[i]
}
accNoDashes := strings.ReplaceAll(acc, "-", "")
filingURL := fmt.Sprintf("https://www.sec.gov/Archives/edgar/data/%s/%s/", cik, accNoDashes)
events = append(events, CompanyEvent{
Ticker: ticker,
EventType: "ceo_change",
Title: fmt.Sprintf("Executive change (8-K §5.02) — %s", ticker),
AccessionNo: acc,
FilingDate: date,
FilingURL: filingURL,
})
}
return events, nil
}
// ---- méthodes internes ----
func (c *Client) lookupCIK(ticker string) (string, error) {
if err := c.loadCIKMap(); err != nil {
return "", err
}
cik, ok := c.cikMap[strings.ToUpper(ticker)]
if !ok {
return "", fmt.Errorf("ticker %s not found", ticker)
}
return cik, nil
}
func (c *Client) loadCIKMap() error {
var loadErr error
c.cikOnce.Do(func() {
resp, err := c.get("https://www.sec.gov/files/company_tickers.json")
if err != nil {
loadErr = err
return
}
defer resp.Body.Close()
var raw map[string]tickerEntry
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
loadErr = err
return
}
c.cikMap = make(map[string]string, len(raw))
for _, entry := range raw {
padded := fmt.Sprintf("%010d", entry.CIK)
c.cikMap[strings.ToUpper(entry.Ticker)] = padded
}
})
return loadErr
}
func (c *Client) recentForm4Filings(cik string, maxDays int) (accessions, docs, dates []string, err error) {
url := fmt.Sprintf("%s/submissions/CIK%s.json", baseURL, cik)
resp, err := c.get(url)
if err != nil {
return nil, nil, nil, err
}
defer resp.Body.Close()
var sub submissionsResponse
if err := json.NewDecoder(resp.Body).Decode(&sub); err != nil {
return nil, nil, nil, err
}
cutoff := time.Now().AddDate(0, 0, -maxDays).Format("2006-01-02")
forms := sub.Filings.Recent.Form
accs := sub.Filings.Recent.AccessionNumber
pdocs := sub.Filings.Recent.PrimaryDocument
fdates := sub.Filings.Recent.FilingDate
for i, form := range forms {
if form != "4" {
continue
}
if i < len(fdates) && fdates[i] < cutoff {
break // filings are sorted newest first, stop when too old
}
if i < len(accs) {
accessions = append(accessions, accs[i])
}
if i < len(pdocs) {
docs = append(docs, pdocs[i])
}
if i < len(fdates) {
dates = append(dates, fdates[i])
}
}
return
}
func (c *Client) parseForm4(cik, accessionNo, primaryDoc, ticker string) ([]InsiderTrade, error) {
// Construire l'URL du document XML
accNoDashes := strings.ReplaceAll(accessionNo, "-", "")
xmlFile := primaryDoc
if xmlFile == "" || !strings.HasSuffix(xmlFile, ".xml") {
// Fallback : essayer le nom conventionnel
xmlFile = accessionNo + ".xml"
}
url := fmt.Sprintf("%s/%s/%s/%s", archiveURL, cik, accNoDashes, xmlFile)
filingURL := fmt.Sprintf("https://www.sec.gov/Archives/edgar/data/%s/%s/%s", cik, accNoDashes, xmlFile)
resp, err := c.get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var doc form4Doc
if err := xml.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, err
}
insiderName := doc.ReportingOwner.ID.Name
insiderTitle := doc.ReportingOwner.Relationship.Title
if insiderTitle == "" {
if doc.ReportingOwner.Relationship.IsDirector == 1 {
insiderTitle = "Director"
} else if doc.ReportingOwner.Relationship.IsOfficer == 1 {
insiderTitle = "Officer"
}
}
var trades []InsiderTrade
for _, tx := range doc.NonDerivativeTable.Transactions {
code := tx.Coding.Code
// P = achat, S = vente, A = attribution avec prix > 0
if code != "P" && code != "S" && !(code == "A" && tx.Amounts.Price.Value > 0) {
continue
}
shares := tx.Amounts.Shares.Value
price := tx.Amounts.Price.Value
if shares <= 0 {
continue
}
trades = append(trades, InsiderTrade{
Ticker: ticker,
InsiderName: insiderName,
InsiderTitle: insiderTitle,
TransactionCode: code,
Shares: shares,
PricePerShare: price,
TotalValue: shares * price,
TransactionDate: tx.Date.Value,
AccessionNo: accessionNo,
FilingURL: filingURL,
})
}
return trades, nil
}
func (c *Client) get(url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json, application/xml, text/xml")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("EDGAR HTTP %d: %s", resp.StatusCode, url)
}
return resp, nil
}