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

301 lines
7.3 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"`
} `json:"recent"`
} `json:"filings"`
}
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.
func (c *Client) RecentInsiderBuys(ticker string) ([]InsiderTrade, error) {
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
}
// ---- 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
// On garde achats (P) et attributions significatives (A avec prix > 0)
if code != "P" && !(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
}