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 }