feat(rubick): rename project and add production release bundle

This commit is contained in:
Muhammad Firas 2026-03-06 03:12:28 +07:00
commit fab6cf26eb
No known key found for this signature in database
33 changed files with 11554 additions and 0 deletions

324
msn/bing_client.go Normal file
View file

@ -0,0 +1,324 @@
package msn
import (
"encoding/json"
"fmt"
"github.com/enetx/g"
"github.com/enetx/surf"
)
// BingClient is the client for Bing Finance APIs (ownership data)
type BingClient struct {
client *surf.Client
}
// NewBingClient creates a new Bing API client with Chrome impersonation
func NewBingClient() *BingClient {
client := surf.NewClient().
Builder().
Impersonate().
Chrome().
Build().
Unwrap()
return &BingClient{client: client}
}
// Close closes idle connections
func (c *BingClient) Close() {
c.client.CloseIdleConnections()
}
// commonHeaders returns common headers for Bing API requests
func (c *BingClient) commonHeaders() map[string]string {
return map[string]string{
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.msn.com",
"Referer": "https://www.msn.com/",
}
}
// GetTopShareHolders fetches top institutional shareholders
func (c *BingClient) GetTopShareHolders(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopShareHolders/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("top shareholders request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("top shareholders API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse top shareholders response: %w", err)
}
// Return whichever field has data
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetTopBuyers fetches recent top buyers
func (c *BingClient) GetTopBuyers(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopBuyers/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("top buyers request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("top buyers API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse top buyers response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetTopSellers fetches recent top sellers
func (c *BingClient) GetTopSellers(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopSellers/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("top sellers request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("top sellers API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse top sellers response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetNewShareHolders fetches new institutional holders
func (c *BingClient) GetNewShareHolders(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopNewShareHolders/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("new shareholders request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("new shareholders API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse new shareholders response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetExitedShareHolders fetches exited institutional holders
func (c *BingClient) GetExitedShareHolders(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopExitedShareHolders/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("exited shareholders request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("exited shareholders API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse exited shareholders response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// IsInvestorDataAvailable checks if investor data exists for a stock
func (c *BingClient) IsInvestorDataAvailable(id string) (bool, error) {
if id == "" {
return false, fmt.Errorf("no stock ID provided")
}
apiURL := fmt.Sprintf("%sIsInvestorDataAvailable/%s",
BingAPIBaseURL,
id,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return false, fmt.Errorf("investor data check request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return false, fmt.Errorf("investor data check API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var available bool
if err := json.Unmarshal([]byte(body), &available); err != nil {
return false, fmt.Errorf("failed to parse investor data check response: %w", err)
}
return available, nil
}
// GetAllOwnership fetches all ownership data for a stock
func (c *BingClient) GetAllOwnership(id string, count int) (*OwnershipData, error) {
ownership := &OwnershipData{}
// Skip IsInvestorDataAvailable check as it often returns 404 even when data exists
// Just try to fetch the data directly
// Fetch all ownership data sequentially
if holders, err := c.GetTopShareHolders(id, count); err == nil {
ownership.TopHolders = holders
}
if buyers, err := c.GetTopBuyers(id, count); err == nil {
ownership.TopBuyers = buyers
}
if sellers, err := c.GetTopSellers(id, count); err == nil {
ownership.TopSellers = sellers
}
if newHolders, err := c.GetNewShareHolders(id, count); err == nil {
ownership.NewHolders = newHolders
}
if exited, err := c.GetExitedShareHolders(id, count); err == nil {
ownership.ExitedHolders = exited
}
return ownership, nil
}

1054
msn/db.go Normal file

File diff suppressed because it is too large Load diff

1844
msn/idx_stocks.go Normal file

File diff suppressed because it is too large Load diff

652
msn/msn_client.go Normal file
View file

@ -0,0 +1,652 @@
package msn
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/enetx/g"
"github.com/enetx/surf"
)
// MSNClientConfig holds configuration for the MSN client
type MSNClientConfig struct {
Proxy string // Proxy URL (http://, https://, socks5://)
RateLimiter *RateLimiter
}
// MSNClient is the base client for MSN Finance APIs
type MSNClient struct {
client *surf.Client
proxy string
rateLimiter *RateLimiter
}
// NewMSNClient creates a new MSN API client with Chrome impersonation
func NewMSNClient() *MSNClient {
return NewMSNClientWithConfig(MSNClientConfig{})
}
// NewMSNClientWithConfig creates a new MSN API client with custom configuration
func NewMSNClientWithConfig(config MSNClientConfig) *MSNClient {
builder := surf.NewClient().
Builder().
Impersonate().
Chrome()
// Add proxy if configured
if config.Proxy != "" {
builder = builder.Proxy(g.String(config.Proxy))
}
client := builder.Build().Unwrap()
return &MSNClient{
client: client,
proxy: config.Proxy,
rateLimiter: config.RateLimiter,
}
}
// waitForRateLimit waits for rate limiter if configured
func (c *MSNClient) waitForRateLimit() {
if c.rateLimiter != nil {
c.rateLimiter.Wait()
}
}
// Close closes idle connections
func (c *MSNClient) Close() {
c.client.CloseIdleConnections()
}
// commonHeaders returns common headers for MSN API requests
func (c *MSNClient) commonHeaders() map[string]string {
return map[string]string{
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9,id;q=0.8",
"Origin": "https://www.msn.com",
"Referer": "https://www.msn.com/",
}
}
// GetQuotes fetches real-time quotes for given stock IDs
func (c *MSNClient) GetQuotes(ids []string) ([]QuoteData, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Quotes?apikey=%s&ids=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("quotes request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("quotes API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var quotes []QuoteData
if err := json.Unmarshal([]byte(body), &quotes); err != nil {
return nil, fmt.Errorf("failed to parse quotes response: %w", err)
}
return quotes, nil
}
// GetQuoteSummary fetches detailed quote summary with multiple intents
func (c *MSNClient) GetQuoteSummary(id string, intents []string) (map[string]json.RawMessage, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
intentStr := strings.Join(intents, ",")
apiURL := fmt.Sprintf("%sFinance/QuoteSummary?apikey=%s&ids=%s&intents=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
id,
intentStr,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("quote summary request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("quote summary API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result []map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse quote summary response: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("empty quote summary response")
}
return result[0], nil
}
// GetCharts fetches historical chart data
func (c *MSNClient) GetCharts(ids []string, chartType string) ([]ChartResponse, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Charts?apikey=%s&cm=id-id&ids=%s&type=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
chartType,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("charts request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("charts API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var charts []ChartResponse
if err := json.Unmarshal([]byte(body), &charts); err != nil {
return nil, fmt.Errorf("failed to parse charts response: %w", err)
}
return charts, nil
}
// GetEquities fetches company information
func (c *MSNClient) GetEquities(ids []string) ([]EquityData, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Equities?apikey=%s&ids=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("equities request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("equities API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var equities []EquityData
if err := json.Unmarshal([]byte(body), &equities); err != nil {
return nil, fmt.Errorf("failed to parse equities response: %w", err)
}
return equities, nil
}
// GetFinancialStatements fetches financial statements
func (c *MSNClient) GetFinancialStatements(id string) (FinancialStatementsResponse, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
// URL encode the filter parameter
filter := fmt.Sprintf("_p eq '%s'", id)
apiURL := fmt.Sprintf("%sFinance/Equities/financialstatements?apikey=%s&$filter=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
url.QueryEscape(filter),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("financial statements request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("financial statements API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
// Response is a direct array of FinancialStatement
var result FinancialStatementsResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse financial statements response: %w", err)
}
return result, nil
}
// GetEarnings fetches earnings events
func (c *MSNClient) GetEarnings(ids []string) ([]EarningsEvent, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Events/Earnings?apikey=%s&ids=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("earnings request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("earnings API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
// Parse the actual API response format
var apiResp EarningsAPIResponse
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse earnings response: %w", err)
}
// Convert quarterly earnings to EarningsEvent array
var earnings []EarningsEvent
for periodKey, data := range apiResp.History.Quarterly {
// Parse fiscal year and quarter from CiqFiscalPeriodType (e.g., "Q42025")
fiscalYear := 0
fiscalQuarter := 0
if len(data.CiqFiscalPeriodType) >= 6 {
// Format: Q{quarter}{year} e.g., Q42025
fmt.Sscanf(data.CiqFiscalPeriodType, "Q%d%d", &fiscalQuarter, &fiscalYear)
}
if fiscalYear == 0 && len(periodKey) >= 6 {
// Fallback: parse from period key (e.g., "202512")
fmt.Sscanf(periodKey[:4], "%d", &fiscalYear)
month := 0
fmt.Sscanf(periodKey[4:6], "%d", &month)
fiscalQuarter = (month-1)/3 + 1
}
// Parse event date
eventDate := ""
if data.EarningReleaseDate != "" {
// Extract date portion from ISO timestamp
if len(data.EarningReleaseDate) >= 10 {
eventDate = data.EarningReleaseDate[:10]
}
}
earnings = append(earnings, EarningsEvent{
ID: fmt.Sprintf("%s_%s", apiResp.InstrumentID, periodKey),
EventDate: eventDate,
FiscalYear: fiscalYear,
FiscalQuarter: fiscalQuarter,
EPSEstimate: data.EpsForecast,
EPSActual: data.EpsActual,
EPSSurprise: data.EpsSurprise,
EPSSurprisePct: data.EpsSurprisePercent,
RevenueEstimate: data.RevenueForecast,
RevenueActual: data.RevenueActual,
RevenueSurprise: data.RevenueSurprise,
})
}
return earnings, nil
}
// GetSentiment fetches market sentiment
func (c *MSNClient) GetSentiment(ids []string) ([]SentimentData, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/SentimentBrowser?apikey=%s&cm=id-id&it=web&scn=ANON&ids=%s&wrapodata=false&flightId=INeedDau",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("sentiment request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("sentiment API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var sentiment []SentimentData
if err := json.Unmarshal([]byte(body), &sentiment); err != nil {
return nil, fmt.Errorf("failed to parse sentiment response: %w", err)
}
return sentiment, nil
}
// GetKeyRatios fetches key financial ratios from api.msn.com
func (c *MSNClient) GetKeyRatios(ids []string) ([]KeyRatios, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%skeyratios?apikey=%s&ids=%s&wrapodata=false",
MSNAPIBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("key ratios request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("key ratios API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var ratios []KeyRatios
if err := json.Unmarshal([]byte(body), &ratios); err != nil {
return nil, fmt.Errorf("failed to parse key ratios response: %w", err)
}
return ratios, nil
}
// GetInsights fetches AI-generated insights from api.msn.com
func (c *MSNClient) GetInsights(id string) (*InsightData, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sinsights?apikey=%s&ids=%s&wrapodata=false",
MSNAPIBaseURL,
MSNAPIKey,
id,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("insights request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("insights API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var insights []InsightData
if err := json.Unmarshal([]byte(body), &insights); err != nil {
return nil, fmt.Errorf("failed to parse insights response: %w", err)
}
if len(insights) == 0 {
return nil, nil
}
return &insights[0], nil
}
// GetNewsFeed fetches stock-related news
func (c *MSNClient) GetNewsFeed(id string) ([]NewsItem, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
// Use the stock-specific entity feed format from MSN website
apiURL := fmt.Sprintf("%sMSN/Feed/me?$top=30&apikey=%s&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_%s&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
id,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("news feed request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("news feed API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var newsFeed NewsFeedResponse
if err := json.Unmarshal([]byte(body), &newsFeed); err != nil {
return nil, fmt.Errorf("failed to parse news feed response: %w", err)
}
// Use SubCards if available (cardview response), otherwise use Value
if len(newsFeed.SubCards) > 0 {
return newsFeed.SubCards, nil
}
return newsFeed.Value, nil
}
// GetAllCharts fetches all chart timeframes for a stock
func (c *MSNClient) GetAllCharts(id string) (map[string][]ChartPoint, error) {
chartTypes := []string{"1D1M", "1M", "3M", "1Y", "3Y"}
result := make(map[string][]ChartPoint)
for _, chartType := range chartTypes {
charts, err := c.GetCharts([]string{id}, chartType)
if err != nil {
continue // Skip failed chart types
}
if len(charts) > 0 {
// Map chart type to friendlier names
typeName := chartType
switch chartType {
case "1D1M":
typeName = "1D"
}
result[typeName] = charts[0].Points
}
}
return result, nil
}
// FetchStockData fetches all data for a single stock
func (c *MSNClient) FetchStockData(id string) (*StockData, error) {
stock := &StockData{
ID: id,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
FetchStatus: make(map[string]string),
Charts: make(map[string][]ChartPoint),
}
// Fetch quote
quotes, err := c.GetQuotes([]string{id})
if err != nil {
stock.FetchStatus["quote"] = fmt.Sprintf("failed: %v", err)
} else if len(quotes) > 0 {
stock.Quote = &quotes[0]
stock.Ticker = quotes[0].Symbol
stock.Name = quotes[0].ShortName
stock.Exchange = quotes[0].ExchangeID
stock.FetchStatus["quote"] = "ok"
}
// Fetch company info
equities, err := c.GetEquities([]string{id})
if err != nil {
stock.FetchStatus["company"] = fmt.Sprintf("failed: %v", err)
} else if len(equities) > 0 {
stock.Company = &equities[0]
stock.Sector = equities[0].Sector
stock.Industry = equities[0].Industry
if stock.Name == "" {
stock.Name = equities[0].ShortName
}
stock.FetchStatus["company"] = "ok"
}
// Fetch charts
charts, err := c.GetAllCharts(id)
if err != nil {
stock.FetchStatus["charts"] = fmt.Sprintf("failed: %v", err)
} else {
stock.Charts = charts
stock.FetchStatus["charts"] = "ok"
}
// Fetch key ratios
ratios, err := c.GetKeyRatios([]string{id})
if err != nil {
stock.FetchStatus["key_ratios"] = fmt.Sprintf("failed: %v", err)
} else if len(ratios) > 0 {
stock.KeyRatios = &ratios[0]
stock.FetchStatus["key_ratios"] = "ok"
}
// Fetch earnings
earnings, err := c.GetEarnings([]string{id})
if err != nil {
stock.FetchStatus["earnings"] = fmt.Sprintf("failed: %v", err)
} else {
stock.Earnings = earnings
stock.FetchStatus["earnings"] = "ok"
}
// Fetch sentiment
sentiment, err := c.GetSentiment([]string{id})
if err != nil {
stock.FetchStatus["sentiment"] = fmt.Sprintf("failed: %v", err)
} else if len(sentiment) > 0 {
stock.Sentiment = &sentiment[0]
stock.FetchStatus["sentiment"] = "ok"
}
// Fetch insights
insights, err := c.GetInsights(id)
if err != nil {
stock.FetchStatus["insights"] = fmt.Sprintf("failed: %v", err)
} else if insights != nil {
stock.Insights = insights
stock.FetchStatus["insights"] = "ok"
}
// Fetch financial statements
financials, err := c.GetFinancialStatements(id)
if err != nil {
stock.FetchStatus["financials"] = fmt.Sprintf("failed: %v", err)
} else if len(financials) > 0 {
stock.Financials = &FinancialData{
Statements: financials,
}
stock.FetchStatus["financials"] = "ok"
}
// Fetch news
news, err := c.GetNewsFeed(id)
if err != nil {
stock.FetchStatus["news"] = fmt.Sprintf("failed: %v", err)
} else {
stock.News = news
stock.FetchStatus["news"] = "ok"
}
return stock, nil
}

228
msn/msn_screener.go Normal file
View file

@ -0,0 +1,228 @@
package msn
import (
"encoding/json"
"fmt"
"github.com/enetx/g"
)
// ScreenerFilter represents available screener filter presets
type ScreenerFilter string
const (
FilterTopPerformers ScreenerFilter = "top-performers"
FilterWorstPerformers ScreenerFilter = "worst-performers"
FilterHighDividend ScreenerFilter = "high-dividend"
FilterLowPE ScreenerFilter = "low-pe"
Filter52WeekHigh ScreenerFilter = "52w-high"
Filter52WeekLow ScreenerFilter = "52w-low"
FilterHighVolume ScreenerFilter = "high-volume"
FilterLargeMarketCap ScreenerFilter = "large-cap"
)
// Filter key mappings for MSN Screener API
var screenerFilterKeys = map[ScreenerFilter]string{
FilterTopPerformers: "st_list_topperfs",
FilterWorstPerformers: "st_list_poorperfs",
FilterHighDividend: "st_list_highdividend",
FilterLowPE: "st_list_lowpe",
Filter52WeekHigh: "st_list_52wkhi",
Filter52WeekLow: "st_list_52wklow",
FilterHighVolume: "st_list_highvol",
FilterLargeMarketCap: "st_list_largecap",
}
// Region key mappings for MSN Screener API
var screenerRegionKeys = map[string]string{
"id": "st_reg_id", // Indonesia
"us": "st_reg_us", // United States
"gb": "st_reg_gb", // United Kingdom
"de": "st_reg_de", // Germany
"jp": "st_reg_jp", // Japan
"hk": "st_reg_hk", // Hong Kong
"sg": "st_reg_sg", // Singapore
"au": "st_reg_au", // Australia
"in": "st_reg_in", // India
"cn": "st_reg_cn", // China
}
// ScreenerConfig holds screener configuration
type ScreenerConfig struct {
Region string // Country code (e.g., "id" for Indonesia)
Filter ScreenerFilter // Filter preset
Limit int // Max results
PageIndex int // Page number (0-indexed)
}
// ScreenerAPIResponse is the raw response from Finance/Screener
type ScreenerAPIResponse struct {
Count int `json:"count"`
MatchIDs []string `json:"matchIds"`
Quote []QuoteData `json:"quote"`
Equity []EquityData `json:"equity"`
Fund []interface{} `json:"fund"`
}
// RunScreener executes the stock screener with given configuration
func (c *MSNClient) RunScreener(config ScreenerConfig) (*ScreenerResponse, error) {
if config.Region == "" {
config.Region = "id" // Default to Indonesia
}
if config.Limit <= 0 {
config.Limit = 50
}
// Build filter array
filters := buildScreenerFilters(config.Region, config.Filter)
req := ScreenerRequest{
Filter: filters,
Order: ScreenerOrder{Key: "st_1yr_asc_order", Dir: "desc"},
ReturnValueType: []string{"quote", "equity"},
ScreenerType: "stock",
Limit: config.Limit,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal screener request: %w", err)
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Screener?apikey=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
)
httpReq := c.client.Post(g.String(apiURL)).
SetHeaders("Content-Type", "text/plain;charset=UTF-8")
for k, v := range c.commonHeaders() {
httpReq = httpReq.SetHeaders(k, v)
}
httpReq = httpReq.Body(g.String(string(reqBody)))
resp := httpReq.Do()
if resp.IsErr() {
return nil, fmt.Errorf("screener request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
body := r.Body.String().Ok().Std()
return nil, fmt.Errorf("screener API returned status %d: %s", r.StatusCode, body)
}
body := r.Body.String().Ok().Std()
var apiResp ScreenerAPIResponse
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse screener response: %w", err)
}
// Merge quote and equity data into ScreenerStock
stocks := mergeScreenerResults(apiResp)
return &ScreenerResponse{
Value: stocks,
Total: apiResp.Count,
Count: apiResp.Count,
MatchIDs: apiResp.MatchIDs,
}, nil
}
// buildScreenerFilters creates filter array based on region and preset
func buildScreenerFilters(region string, filter ScreenerFilter) []ScreenerFilterItem {
filters := make([]ScreenerFilterItem, 0, 2)
// Add filter preset
if filterKey, ok := screenerFilterKeys[filter]; ok {
filters = append(filters, ScreenerFilterItem{
Key: filterKey,
KeyGroup: "st_list_",
IsRange: false,
})
}
// Add region filter
if regionKey, ok := screenerRegionKeys[region]; ok {
filters = append(filters, ScreenerFilterItem{
Key: regionKey,
KeyGroup: "st_reg_",
IsRange: false,
})
}
return filters
}
// mergeScreenerResults combines quote and equity data into ScreenerStock slice
func mergeScreenerResults(apiResp ScreenerAPIResponse) []ScreenerStock {
// Build equity map by instrumentId
equityMap := make(map[string]*EquityData)
for i := range apiResp.Equity {
eq := &apiResp.Equity[i]
// Use instrumentId from the "_p" field if available
if id := eq.ID; id != "" {
equityMap[id] = eq
}
}
stocks := make([]ScreenerStock, 0, len(apiResp.Quote))
for _, q := range apiResp.Quote {
stock := ScreenerStock{
ID: q.InstrumentID,
InstrumentID: q.InstrumentID,
Symbol: q.Symbol,
ShortName: q.ShortName,
DisplayName: q.DisplayName,
ExchangeID: q.ExchangeID,
ExchangeCode: q.ExchangeCode,
Country: q.Country,
Price: q.Price,
PriceChange: q.PriceChange,
PriceChangePct: q.PriceChangePct,
MarketCap: q.MarketCap,
Volume: q.AccumulatedVolume,
Price52wHigh: q.Price52wHigh,
Price52wLow: q.Price52wLow,
Return1Year: q.Return1Year,
ReturnYTD: q.ReturnYTD,
}
// Merge equity data if available
if eq, ok := equityMap[q.InstrumentID]; ok {
stock.Sector = eq.Sector
stock.Industry = eq.Industry
}
stocks = append(stocks, stock)
}
return stocks
}
// ParseScreenerFilter converts string to ScreenerFilter
func ParseScreenerFilter(s string) (ScreenerFilter, error) {
switch s {
case "top-performers", "top":
return FilterTopPerformers, nil
case "worst-performers", "worst":
return FilterWorstPerformers, nil
case "high-dividend", "dividend":
return FilterHighDividend, nil
case "low-pe", "pe":
return FilterLowPE, nil
case "52w-high", "52high":
return Filter52WeekHigh, nil
case "52w-low", "52low":
return Filter52WeekLow, nil
case "high-volume", "volume":
return FilterHighVolume, nil
case "large-cap", "largecap":
return FilterLargeMarketCap, nil
default:
return "", fmt.Errorf("unknown filter: %s (valid: top-performers, worst-performers, high-dividend, low-pe, 52w-high, 52w-low, high-volume, large-cap)", s)
}
}

353
msn/msn_stock.go Normal file
View file

@ -0,0 +1,353 @@
package msn
import (
"context"
"fmt"
"log"
"sync"
"time"
)
// StockFetcher handles parallel fetching of stock data
type StockFetcher struct {
msnClient *MSNClient
bingClient *BingClient
}
// NewStockFetcher creates a new stock fetcher
func NewStockFetcher() *StockFetcher {
return &StockFetcher{
msnClient: NewMSNClient(),
bingClient: NewBingClient(),
}
}
// Close closes all clients
func (f *StockFetcher) Close() {
f.msnClient.Close()
f.bingClient.Close()
}
// FetchResult holds the result of fetching a single stock
type StockFetchResult struct {
Index int
Stock *StockData
Error error
}
// FetchStocks fetches data for multiple stocks in parallel
func (f *StockFetcher) FetchStocks(ctx context.Context, ids []string, concurrency int) []StockData {
if concurrency <= 0 {
concurrency = 5
}
results := make([]StockData, len(ids))
// Create work channel
work := make(chan int, len(ids))
for i := range ids {
work <- i
}
close(work)
// Worker pool
var wg sync.WaitGroup
var mu sync.Mutex
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case idx, ok := <-work:
if !ok {
return
}
id := ids[idx]
stock := f.fetchSingleStock(ctx, id)
mu.Lock()
results[idx] = *stock
mu.Unlock()
// Count successful fetches
successCount := 0
for k, v := range stock.FetchStatus {
if v == "ok" {
successCount++
}
_ = k
}
log.Printf("[%d/%d] %s (%s) - %d/%d APIs succeeded",
idx+1, len(ids),
stock.Ticker,
stock.ID,
successCount,
len(stock.FetchStatus),
)
}
}
}()
}
wg.Wait()
return results
}
// fetchSingleStock fetches all data for a single stock
func (f *StockFetcher) fetchSingleStock(ctx context.Context, id string) *StockData {
stock := &StockData{
ID: id,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
FetchStatus: make(map[string]string),
Charts: make(map[string][]ChartPoint),
}
// Use channels for parallel fetching within a single stock
type fetchResult struct {
name string
err error
data interface{}
}
resultChan := make(chan fetchResult, 10)
var fetchWg sync.WaitGroup
// Fetch quote
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
quotes, err := f.msnClient.GetQuotes([]string{id})
if err != nil {
resultChan <- fetchResult{name: "quote", err: err}
return
}
if len(quotes) > 0 {
resultChan <- fetchResult{name: "quote", data: &quotes[0]}
}
}()
// Fetch company info
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
equities, err := f.msnClient.GetEquities([]string{id})
if err != nil {
resultChan <- fetchResult{name: "company", err: err}
return
}
if len(equities) > 0 {
resultChan <- fetchResult{name: "company", data: &equities[0]}
}
}()
// Fetch key ratios
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
ratios, err := f.msnClient.GetKeyRatios([]string{id})
if err != nil {
resultChan <- fetchResult{name: "key_ratios", err: err}
return
}
if len(ratios) > 0 {
resultChan <- fetchResult{name: "key_ratios", data: &ratios[0]}
}
}()
// Fetch earnings
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
earnings, err := f.msnClient.GetEarnings([]string{id})
if err != nil {
resultChan <- fetchResult{name: "earnings", err: err}
return
}
resultChan <- fetchResult{name: "earnings", data: earnings}
}()
// Fetch sentiment
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
sentiment, err := f.msnClient.GetSentiment([]string{id})
if err != nil {
resultChan <- fetchResult{name: "sentiment", err: err}
return
}
if len(sentiment) > 0 {
resultChan <- fetchResult{name: "sentiment", data: &sentiment[0]}
}
}()
// Fetch insights
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
insights, err := f.msnClient.GetInsights(id)
if err != nil {
resultChan <- fetchResult{name: "insights", err: err}
return
}
resultChan <- fetchResult{name: "insights", data: insights}
}()
// Fetch financial statements
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
financials, err := f.msnClient.GetFinancialStatements(id)
if err != nil {
resultChan <- fetchResult{name: "financials", err: err}
return
}
resultChan <- fetchResult{name: "financials", data: financials}
}()
// Fetch news
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
news, err := f.msnClient.GetNewsFeed(id)
if err != nil {
resultChan <- fetchResult{name: "news", err: err}
return
}
resultChan <- fetchResult{name: "news", data: news}
}()
// Fetch charts (all timeframes)
chartTypes := []string{"1D1M", "1M", "3M", "1Y", "3Y"}
for _, chartType := range chartTypes {
ct := chartType
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
charts, err := f.msnClient.GetCharts([]string{id}, ct)
if err != nil {
return // Skip failed chart types silently
}
if len(charts) > 0 {
points := charts[0].ToChartPoints()
if len(points) > 0 {
typeName := ct
if ct == "1D1M" {
typeName = "1D"
}
resultChan <- fetchResult{name: "chart_" + typeName, data: points}
}
}
}()
}
// Fetch ownership data from Bing
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
ownership, err := f.bingClient.GetAllOwnership(id, 20)
if err != nil {
resultChan <- fetchResult{name: "ownership", err: err}
return
}
resultChan <- fetchResult{name: "ownership", data: ownership}
}()
// Close result channel when all fetches complete
go func() {
fetchWg.Wait()
close(resultChan)
}()
// Collect results
for result := range resultChan {
if result.err != nil {
stock.FetchStatus[result.name] = fmt.Sprintf("failed: %v", result.err)
continue
}
switch result.name {
case "quote":
if quote, ok := result.data.(*QuoteData); ok && quote != nil {
stock.Quote = quote
stock.Ticker = quote.Symbol
stock.Name = quote.ShortName
stock.Exchange = quote.ExchangeID
stock.FetchStatus["quote"] = "ok"
}
case "company":
if equity, ok := result.data.(*EquityData); ok && equity != nil {
stock.Company = equity
stock.Sector = equity.Sector
stock.Industry = equity.Industry
if stock.Name == "" {
stock.Name = equity.ShortName
}
stock.FetchStatus["company"] = "ok"
}
case "key_ratios":
if ratios, ok := result.data.(*KeyRatios); ok && ratios != nil {
stock.KeyRatios = ratios
stock.FetchStatus["key_ratios"] = "ok"
}
case "earnings":
if earnings, ok := result.data.([]EarningsEvent); ok {
stock.Earnings = earnings
stock.FetchStatus["earnings"] = "ok"
}
case "sentiment":
if sentiment, ok := result.data.(*SentimentData); ok && sentiment != nil {
stock.Sentiment = sentiment
stock.FetchStatus["sentiment"] = "ok"
}
case "insights":
if insights, ok := result.data.(*InsightData); ok && insights != nil {
stock.Insights = insights
stock.FetchStatus["insights"] = "ok"
}
case "financials":
if financials, ok := result.data.(FinancialStatementsResponse); ok && len(financials) > 0 {
stock.Financials = &FinancialData{
Statements: financials,
}
stock.FetchStatus["financials"] = "ok"
}
case "news":
if news, ok := result.data.([]NewsItem); ok {
stock.News = news
stock.FetchStatus["news"] = "ok"
}
case "ownership":
if ownership, ok := result.data.(*OwnershipData); ok && ownership != nil {
stock.Ownership = ownership
stock.FetchStatus["ownership"] = "ok"
}
default:
// Handle chart results
if len(result.name) > 6 && result.name[:6] == "chart_" {
chartType := result.name[6:]
if points, ok := result.data.([]ChartPoint); ok {
stock.Charts[chartType] = points
stock.FetchStatus["charts"] = "ok"
}
}
}
}
return stock
}
// FetchStockByID fetches a single stock by ID
func (f *StockFetcher) FetchStockByID(ctx context.Context, id string) (*StockData, error) {
stocks := f.FetchStocks(ctx, []string{id}, 1)
if len(stocks) == 0 {
return nil, fmt.Errorf("failed to fetch stock %s", id)
}
return &stocks[0], nil
}

550
msn/msn_types.go Normal file
View file

@ -0,0 +1,550 @@
package msn
// MSN API Constants
const (
MSNAssetsBaseURL = "https://assets.msn.com/service/"
MSNAPIBaseURL = "https://api.msn.com/msn/v0/pages/finance/"
BingAPIBaseURL = "https://services.bingapis.com/contentservices-finance.hedgefunddataprovider/api/v1/"
// Public API key from MSN Money website
MSNAPIKey = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM"
)
// ScreenerRequest is the POST body for Finance/Screener
// Uses the actual MSN API format with predefined filter keys
type ScreenerRequest struct {
Filter []ScreenerFilterItem `json:"filter"`
Order ScreenerOrder `json:"order"`
ReturnValueType []string `json:"returnValueType"`
ScreenerType string `json:"screenerType"`
Limit int `json:"limit"`
}
// ScreenerFilterItem represents a filter condition in the screener
type ScreenerFilterItem struct {
Key string `json:"key"` // e.g., "st_list_topperfs", "st_reg_id"
KeyGroup string `json:"keyGroup"` // e.g., "st_list_", "st_reg_"
IsRange bool `json:"isRange"`
}
// ScreenerOrder represents sort order for screener results
type ScreenerOrder struct {
Key string `json:"key"` // e.g., "st_1yr_asc_order"
Dir string `json:"dir"` // "asc" or "desc"
}
// ScreenerResponse from Finance/Screener
type ScreenerResponse struct {
Value []ScreenerStock `json:"value"`
Total int `json:"total"`
Count int `json:"count"`
MatchIDs []string `json:"matchIds"`
Equity []ScreenerStock `json:"equity"`
Quote []QuoteData `json:"quote"`
}
// ScreenerStock is a stock from screener results
type ScreenerStock struct {
ID string `json:"id"`
InstrumentID string `json:"instrumentId,omitempty"`
Symbol string `json:"symbol"`
ShortName string `json:"shortName"`
DisplayName string `json:"displayName,omitempty"`
ExchangeID string `json:"exchangeId"`
ExchangeCode string `json:"exchangeCode,omitempty"`
Country string `json:"country,omitempty"`
Sector string `json:"sector,omitempty"`
Industry string `json:"industry,omitempty"`
Price float64 `json:"price"`
PriceChange float64 `json:"priceChange"`
PriceChangePct float64 `json:"priceChangePercent"`
MarketCap float64 `json:"marketCap"`
Volume float64 `json:"accumulatedVolume"`
Price52wHigh float64 `json:"price52wHigh"`
Price52wLow float64 `json:"price52wLow"`
Return1Year float64 `json:"return1Year"`
ReturnYTD float64 `json:"returnYTD"`
}
// QuoteResponse from Finance/Quotes
type QuoteResponse []QuoteData
// QuoteData represents real-time quote data
type QuoteData struct {
ID string `json:"id"`
InstrumentID string `json:"instrumentId"`
Symbol string `json:"symbol"`
ShortName string `json:"shortName"`
DisplayName string `json:"displayName"`
Price float64 `json:"price"`
PriceChange float64 `json:"priceChange"`
PriceChangePct float64 `json:"priceChangePercent"`
PriceDayOpen float64 `json:"priceDayOpen"`
PriceDayHigh float64 `json:"priceDayHigh"`
PriceDayLow float64 `json:"priceDayLow"`
PricePreviousClose float64 `json:"pricePreviousClose"`
PriceClose float64 `json:"priceClose"`
Price52wHigh float64 `json:"price52wHigh"`
Price52wLow float64 `json:"price52wLow"`
AccumulatedVolume float64 `json:"accumulatedVolume"`
AverageVolume float64 `json:"averageVolume"`
MarketCap float64 `json:"marketCap"`
MarketCapCurrency string `json:"marketCapCurrency"`
ExchangeID string `json:"exchangeId"`
ExchangeCode string `json:"exchangeCode"`
ExchangeName string `json:"exchangeName"`
Currency string `json:"currency"`
Country string `json:"country"`
Market string `json:"market"`
TimeLastTraded string `json:"timeLastTraded"`
TimeLastUpdated string `json:"timeLastUpdated"`
// Historical price changes
PriceChange1Week float64 `json:"priceChange1Week"`
PriceChange1Month float64 `json:"priceChange1Month"`
PriceChange3Month float64 `json:"priceChange3Month"`
PriceChange6Month float64 `json:"priceChange6Month"`
PriceChangeYTD float64 `json:"priceChangeYTD"`
PriceChange1Year float64 `json:"priceChange1Year"`
// Historical returns (percentage)
Return1Week float64 `json:"return1Week"`
Return1Month float64 `json:"return1Month"`
Return3Month float64 `json:"return3Month"`
Return6Month float64 `json:"return6Month"`
ReturnYTD float64 `json:"returnYTD"`
Return1Year float64 `json:"return1Year"`
}
// QuoteSummaryResponse from Finance/QuoteSummary
type QuoteSummaryResponse []struct {
Quotes []QuoteData `json:"quotes"`
Exchanges []ExchangeData `json:"exchanges"`
Details []QuoteDetail `json:"quoteDetails"`
ChartData []ChartResponse `json:"charts"`
}
// ExchangeData from Finance/Exchanges
type ExchangeData struct {
ID string `json:"id"`
Name string `json:"name"`
Country string `json:"country"`
Timezone string `json:"timeZone"`
}
// QuoteDetail provides extended quote information
type QuoteDetail struct {
ID string `json:"id"`
Beta float64 `json:"beta"`
TrailingPE float64 `json:"trailingPE"`
ForwardPE float64 `json:"forwardPE"`
PriceToBook float64 `json:"priceToBook"`
PriceToSales float64 `json:"priceToSales"`
EnterpriseValue float64 `json:"enterpriseValue"`
EBITDA float64 `json:"ebitda"`
Revenue float64 `json:"revenue"`
GrossProfit float64 `json:"grossProfit"`
FreeCashFlow float64 `json:"freeCashFlow"`
DebtToEquity float64 `json:"debtToEquity"`
QuickRatio float64 `json:"quickRatio"`
CurrentRatio float64 `json:"currentRatio"`
ReturnOnEquity float64 `json:"returnOnEquity"`
ReturnOnAssets float64 `json:"returnOnAssets"`
ProfitMargin float64 `json:"profitMargin"`
OperatingMargin float64 `json:"operatingMargin"`
GrossMargin float64 `json:"grossMargin"`
}
// ChartResponse from Finance/Charts
type ChartResponse struct {
ID string `json:"_p"`
ChartType string `json:"chartType"` // "1D1M", "1M", "3M", "1Y", "3Y"
Symbol string `json:"symbol"`
Series ChartSeriesData `json:"series"`
Points []ChartPoint `json:"-"` // Computed from Series
}
// ChartSeriesData is the raw series data from the API
type ChartSeriesData struct {
TimeStamps []string `json:"timeStamps"`
Prices []float64 `json:"prices"`
OpenPrices []float64 `json:"openPrices"`
PricesHigh []float64 `json:"pricesHigh"`
PricesLow []float64 `json:"pricesLow"`
Volumes []float64 `json:"volumes"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
PriceHigh float64 `json:"priceHigh"`
PriceLow float64 `json:"priceLow"`
}
// ToChartPoints converts the series data into chart points
func (c *ChartResponse) ToChartPoints() []ChartPoint {
if len(c.Series.TimeStamps) == 0 {
return nil
}
points := make([]ChartPoint, len(c.Series.TimeStamps))
for i, ts := range c.Series.TimeStamps {
point := ChartPoint{Time: ts}
if i < len(c.Series.Prices) {
point.Price = c.Series.Prices[i]
point.Close = c.Series.Prices[i]
}
if i < len(c.Series.OpenPrices) {
point.Open = c.Series.OpenPrices[i]
}
if i < len(c.Series.PricesHigh) {
point.High = c.Series.PricesHigh[i]
}
if i < len(c.Series.PricesLow) {
point.Low = c.Series.PricesLow[i]
}
if i < len(c.Series.Volumes) {
point.Volume = int64(c.Series.Volumes[i])
}
points[i] = point
}
return points
}
// ChartPoint is a single data point in a chart
type ChartPoint struct {
Time string `json:"time"`
Price float64 `json:"price"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume int64 `json:"volume"`
}
// EquityResponse from Finance/Equities
type EquityResponse []EquityData
// EquityData represents company information
type EquityData struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
ShortName string `json:"shortName"`
LongName string `json:"longName"`
Description string `json:"description"`
Sector string `json:"sector"`
Industry string `json:"industry"`
Website string `json:"website"`
Employees int `json:"fullTimeEmployees"`
Address string `json:"address"`
City string `json:"city"`
Country string `json:"country"`
Phone string `json:"phone"`
Officers []Officer `json:"officers"`
}
// Officer represents a company executive
type Officer struct {
Name string `json:"name"`
Title string `json:"title"`
Age int `json:"age"`
YearBorn int `json:"yearBorn"`
TotalPay int64 `json:"totalPay"`
}
// FinancialStatementsResponse from Finance/Equities/financialstatements
// Response is an array of FinancialStatement objects
type FinancialStatementsResponse []FinancialStatement
// FinancialStatement represents comprehensive financial data
type FinancialStatement struct {
UnderlyingInstrument InstrumentInfo `json:"underlyingInstrument"`
BalanceSheets *BalanceSheet `json:"balanceSheets"`
CashFlow *CashFlowData `json:"cashFlow"`
IncomeStatements *IncomeStatement `json:"incomeStatements"`
}
// InstrumentInfo contains basic stock information
type InstrumentInfo struct {
InstrumentID string `json:"instrumentId"`
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
ExchangeID string `json:"exchangeId"`
ExchangeCode string `json:"exchangeCode"`
SecurityType string `json:"securityType"`
Symbol string `json:"symbol"`
}
// BalanceSheet represents balance sheet data
type BalanceSheet struct {
CurrentAssets map[string]float64 `json:"currentAssets"`
LongTermAssets map[string]float64 `json:"longTermAssets"`
CurrentLiabilities map[string]float64 `json:"currentLiabilities"`
Equity map[string]float64 `json:"equity"`
Currency string `json:"currency"`
Source string `json:"source"`
SourceDate string `json:"sourceDate"`
ReportDate string `json:"reportDate"`
EndDate string `json:"endDate"`
}
// CashFlowData represents cash flow statement
type CashFlowData struct {
Financing map[string]float64 `json:"financing"`
Investing map[string]float64 `json:"investing"`
Operating map[string]float64 `json:"operating"`
Currency string `json:"currency"`
Source string `json:"source"`
EndDate string `json:"endDate"`
}
// IncomeStatement represents income statement data
type IncomeStatement struct {
Revenue map[string]float64 `json:"revenue"`
Expenses map[string]float64 `json:"expenses"`
Currency string `json:"currency"`
Source string `json:"source"`
EndDate string `json:"endDate"`
}
// KeyRatiosResponse from api.msn.com keyratios
type KeyRatiosResponse []KeyRatios
// KeyRatios represents financial ratios with historical data
type KeyRatios struct {
StockID string `json:"stockId"`
ExchangeID string `json:"exchangeId"`
Market string `json:"market"`
Industry string `json:"industry"`
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
Symbol string `json:"symbol"`
IndustryMetrics []IndustryMetric `json:"industryMetrics"`
}
// IndustryMetric represents financial metrics for a specific year
type IndustryMetric struct {
Year string `json:"year"`
FiscalPeriodType string `json:"fiscalPeriodType"`
RevenuePerShare float64 `json:"revenuePerShare"`
EarningsPerShare float64 `json:"earningsPerShare"`
FreeCashFlowPerShare float64 `json:"freeCashFlowPerShare"`
DividendPerShare float64 `json:"dividendPerShare"`
BookValuePerShare float64 `json:"bookValuePerShare"`
RevenueGrowthRate float64 `json:"revenueGrowthRate"`
EarningsGrowthRate float64 `json:"earningsGrowthRate"`
GrossMargin float64 `json:"grossMargin"`
OperatingMargin float64 `json:"operatingMargin"`
NetMargin float64 `json:"netMargin"`
ROE float64 `json:"roe"`
ROIC float64 `json:"roic"`
ROA float64 `json:"returnOnAssetCurrent"`
DebtToEquityRatio float64 `json:"debtToEquityRatio"`
DebtToEBITDA float64 `json:"debtToEbitda"`
FinancialLeverage float64 `json:"financialLeverage"`
QuickRatio float64 `json:"quickRatio"`
CurrentRatio float64 `json:"currentRatio"`
AssetTurnover float64 `json:"assetTurnover"`
InventoryTurnover float64 `json:"inventoryTurnover"`
ReceivableTurnover float64 `json:"receivableTurnover"`
PayoutRatio float64 `json:"payoutRatio"`
PriceToSalesRatio float64 `json:"priceToSalesRatio"`
PriceToEarningsRatio float64 `json:"priceToEarningsRatio"`
PriceToCashFlowRatio float64 `json:"priceToCashFlowRatio"`
PriceToBookRatio float64 `json:"priceToBookRatio"`
EVToEBITDA float64 `json:"evEbitda"`
}
// EarningsAPIResponse represents the actual API response from Finance/Events/Earnings
type EarningsAPIResponse struct {
History struct {
Quarterly map[string]EarningsData `json:"quarterly"`
Annual map[string]EarningsData `json:"annual"`
} `json:"History"`
InstrumentID string `json:"InstrumentId"`
Symbol string `json:"Symbol"`
}
// EarningsData represents a single earnings report from the API
type EarningsData struct {
EpsActual float64 `json:"EpsActual"`
EpsSurprise float64 `json:"EpsSurprise"`
EpsSurprisePercent float64 `json:"EpsSurprisePercent"`
EpsForecast float64 `json:"EpsForecast"`
RevenueActual float64 `json:"RevenueActual"`
RevenueSurprise float64 `json:"RevenueSurprise"`
RevenueForecast float64 `json:"RevenueForecast"`
EarningReleaseDate string `json:"EarningReleaseDate"`
CiqFiscalPeriodType string `json:"CiqFiscalPeriodType"` // e.g., "Q42025", "Q12026"
CalendarPeriodType string `json:"CalendarPeriodType"`
}
// EarningsEvent represents a normalized earnings event for storage
type EarningsEvent struct {
ID string `json:"id"`
EventDate string `json:"eventDate"`
FiscalYear int `json:"fiscalYear"`
FiscalQuarter int `json:"fiscalQuarter"`
EPSEstimate float64 `json:"epsEstimate"`
EPSActual float64 `json:"epsActual"`
EPSSurprise float64 `json:"epsSurprise"`
EPSSurprisePct float64 `json:"epsSurprisePercent"`
RevenueEstimate float64 `json:"revenueEstimate"`
RevenueActual float64 `json:"revenueActual"`
RevenueSurprise float64 `json:"revenueSurprise"`
}
// SentimentResponse from Finance/SentimentBrowser
type SentimentResponse []SentimentData
// SentimentData represents market sentiment for a stock
type SentimentData struct {
DisplayName string `json:"displayName"`
Market string `json:"market"`
InstrumentID string `json:"instrumentId"`
Symbol string `json:"symbol"`
SentimentStatistics []SentimentStatistic `json:"sentimentStatistics"`
}
// SentimentStatistic represents sentiment data for a time period
type SentimentStatistic struct {
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
TimeRangeName string `json:"timeRangeName"`
TimeRangeEnum string `json:"timeRangeEnum"`
Bullish int `json:"bullish"`
Bearish int `json:"bearish"`
Neutral int `json:"neutral"`
BullishPercent float64 `json:"bullishPercent"`
BearishPercent float64 `json:"bearishPercent"`
NeutralPercent float64 `json:"neutralPercent"`
Scenario string `json:"scenairo"` // Note: API has typo "scenairo"
}
// InsightsResponse from api.msn.com insights
type InsightsResponse []InsightData
// InsightData represents AI-generated stock insights
type InsightData struct {
ID string `json:"id"`
Summary string `json:"summary"`
Highlights []string `json:"highlights"`
Risks []string `json:"risks"`
LastUpdated string `json:"lastUpdated"`
}
// NewsFeedResponse from MSN/Feed/me
type NewsFeedResponse struct {
Value []NewsItem `json:"value"`
SubCards []NewsItem `json:"subCards"`
}
// NewsItem represents a news article
type NewsItem struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"abstract"`
Provider *NewsProvider `json:"provider"`
PublishTime string `json:"publishedDateTime"`
Images []NewsImage `json:"images"`
ReadTimeMin int `json:"readTimeMin"`
}
// NewsProvider represents a news provider
type NewsProvider struct {
ID string `json:"id"`
Name string `json:"name"`
}
// NewsImage represents a news article image
type NewsImage struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
// Holder represents an institutional holder
type Holder struct {
Name string `json:"investorName"`
Type string `json:"investorType"`
SharesHeld int64 `json:"sharesHeld"`
SharesChange int64 `json:"sharesChange"`
SharesPct float64 `json:"sharesPercent"`
Value float64 `json:"value"`
ReportDate string `json:"reportDate"`
}
// OwnershipResponse from Bing API
type OwnershipResponse struct {
Records []Holder `json:"records"`
SecurityOwnerships []Holder `json:"securityOwnerships"`
Total int `json:"total"`
}
// OwnershipData aggregates all ownership information
type OwnershipData struct {
TopHolders []Holder `json:"top_holders"`
TopBuyers []Holder `json:"top_buyers"`
TopSellers []Holder `json:"top_sellers"`
NewHolders []Holder `json:"new_holders"`
ExitedHolders []Holder `json:"exited_holders"`
}
// StockData is the complete stock information output
type StockData struct {
ID string `json:"id"`
Ticker string `json:"ticker"`
Name string `json:"name"`
Exchange string `json:"exchange"`
Sector string `json:"sector"`
Industry string `json:"industry"`
// Real-time data
Quote *QuoteData `json:"quote,omitempty"`
// Historical Charts
Charts map[string][]ChartPoint `json:"charts,omitempty"`
// Fundamentals
Financials *FinancialData `json:"financials,omitempty"`
KeyRatios *KeyRatios `json:"key_ratios,omitempty"`
// Company Info
Company *EquityData `json:"company,omitempty"`
// Events
Earnings []EarningsEvent `json:"earnings,omitempty"`
// Analysis
Sentiment *SentimentData `json:"sentiment,omitempty"`
Insights *InsightData `json:"insights,omitempty"`
// Ownership (Bing API)
Ownership *OwnershipData `json:"ownership,omitempty"`
// News
News []NewsItem `json:"news,omitempty"`
// Metadata
FetchedAt string `json:"fetched_at"`
FetchStatus map[string]string `json:"fetch_status"`
}
// FinancialData aggregates all financial statements
type FinancialData struct {
Statements []FinancialStatement `json:"statements,omitempty"`
}
// ScreenerOutput is the JSON output for screener command
type ScreenerOutput struct {
Filter string `json:"filter"`
Region string `json:"region"`
GeneratedAt string `json:"generated_at"`
Total int `json:"total"`
Stocks []ScreenerStock `json:"stocks"`
}
// FetchOutput is the JSON output for fetch command
type FetchOutput struct {
GeneratedAt string `json:"generated_at"`
Total int `json:"total"`
Stocks []StockData `json:"stocks"`
}

194
msn/news_analysis.go Normal file
View file

@ -0,0 +1,194 @@
package msn
import (
"strings"
)
// News categories
const (
CategoryEarnings = "earnings"
CategoryDividend = "dividend"
CategoryCorporateAction = "corporate_action"
CategoryRegulation = "regulation"
CategoryRating = "rating"
CategoryExpansion = "expansion"
CategoryLeadership = "leadership"
CategoryMarket = "market"
CategoryGeneral = "general"
)
// Sentiment types
const (
SentimentPositive = "positive"
SentimentNegative = "negative"
SentimentNeutral = "neutral"
)
// Category keywords (Indonesian + English)
var categoryKeywords = map[string][]string{
CategoryEarnings: {
"laba", "rugi", "earnings", "profit", "net income", "pendapatan",
"revenue", "keuntungan", "kerugian", "loss", "income", "untung",
"quarterly", "kuartalan", "annual report", "laporan tahunan",
"eps", "earning per share",
},
CategoryDividend: {
"dividen", "dividend", "pembagian", "interim", "final dividend",
"cum date", "ex date", "payment date", "tanggal pembayaran",
"yield", "payout",
},
CategoryCorporateAction: {
"akuisisi", "merger", "acquisition", "rights issue", "stock split",
"reverse split", "buyback", "ipo", "penawaran umum", "private placement",
"tender offer", "spin off", "spinoff", "demerger", "konsolidasi",
"rights", "waran", "warrant", "obligasi", "bond", "sukuk",
},
CategoryRegulation: {
"ojk", "regulasi", "peraturan", "kebijakan", "regulation", "policy",
"compliance", "kepatuhan", "lisensi", "license", "izin", "permit",
"pemerintah", "government", "bapepam", "bei", "idx", "bursa",
},
CategoryRating: {
"rating", "peringkat", "upgrade", "downgrade", "outlook",
"stable", "positive", "negative", "credit rating", "moody",
"fitch", "s&p", "pefindo", "target price", "rekomendasi",
"buy", "sell", "hold", "analyst",
},
CategoryExpansion: {
"ekspansi", "expansion", "investasi", "investment", "proyek baru",
"new project", "pabrik", "factory", "plant", "cabang", "branch",
"pembangunan", "construction", "development", "joint venture", "jv",
"kerjasama", "partnership", "kontrak", "contract",
},
CategoryLeadership: {
"direktur", "director", "komisaris", "commissioner", "ceo", "cfo",
"president director", "management", "manajemen", "direksi",
"rups", "agm", "annual general meeting", "pengangkatan", "appointment",
"pengunduran", "resignation", "pergantian", "change",
},
CategoryMarket: {
"ihsg", "idx", "pasar modal", "bursa", "market", "saham",
"stock", "trading", "perdagangan", "volume", "kapitalisasi",
"market cap", "blue chip", "lq45", "idx80", "kompas100",
},
}
// Positive sentiment keywords
var positiveKeywords = []string{
// Indonesian
"naik", "untung", "tumbuh", "positif", "optimis", "meningkat",
"surplus", "berhasil", "sukses", "cemerlang", "bagus", "baik",
"membaik", "melonjak", "meroket", "tertinggi", "rekor",
"peningkatan", "pertumbuhan", "keuntungan", "laba bersih",
"ekspansi", "pemulihan", "recovery",
// English
"rise", "gain", "growth", "positive", "optimistic", "increase",
"surplus", "success", "excellent", "good", "improve", "surge",
"soar", "highest", "record", "profit", "expansion", "recovery",
"bullish", "upgrade", "beat", "exceed", "outperform",
}
// Negative sentiment keywords
var negativeKeywords = []string{
// Indonesian
"turun", "rugi", "anjlok", "negatif", "pesimis", "menurun",
"defisit", "gagal", "buruk", "memburuk", "jatuh", "tertekan",
"terendah", "penurunan", "kerugian", "merosot", "melemah",
"default", "bangkrut", "pailit", "koreksi", "tekanan",
// English
"fall", "loss", "plunge", "negative", "pessimistic", "decrease",
"deficit", "fail", "bad", "worsen", "drop", "pressure",
"lowest", "decline", "weak", "default", "bankrupt", "correction",
"bearish", "downgrade", "miss", "underperform", "concern", "risk",
}
// Critical news keywords (alerts)
var criticalKeywords = []string{
// Indonesian
"suspend", "suspensi", "fraud", "penipuan", "korupsi", "corruption",
"default", "gagal bayar", "bangkrut", "pailit", "bankruptcy",
"delisting", "pencabutan", "investigasi", "investigation",
"skandal", "scandal", "illegal", "ilegal", "pelanggaran", "violation",
"tuntutan", "lawsuit", "gugatan", "denda", "fine", "sanksi", "sanction",
"pkpu", "penundaan", "moratorium", "restrukturisasi utang",
// English
"suspend", "fraud", "corruption", "default", "bankrupt", "bankruptcy",
"delisting", "investigation", "scandal", "illegal", "violation",
"lawsuit", "fine", "sanction", "debt restructuring", "warning",
"material adverse", "going concern", "audit opinion", "disclaimer",
}
// categorizeNews determines the category of a news article
func categorizeNews(title, abstract string) string {
text := strings.ToLower(title + " " + abstract)
// Check each category
maxScore := 0
bestCategory := CategoryGeneral
for category, keywords := range categoryKeywords {
score := 0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score++
}
}
if score > maxScore {
maxScore = score
bestCategory = category
}
}
return bestCategory
}
// scoreNewsSentiment analyzes sentiment of a news article
func scoreNewsSentiment(title, abstract string) (sentiment string, score float64) {
text := strings.ToLower(title + " " + abstract)
positiveScore := 0
negativeScore := 0
for _, keyword := range positiveKeywords {
if strings.Contains(text, keyword) {
positiveScore++
}
}
for _, keyword := range negativeKeywords {
if strings.Contains(text, keyword) {
negativeScore++
}
}
totalScore := positiveScore + negativeScore
if totalScore == 0 {
return SentimentNeutral, 0.0
}
// Calculate score from -1 (very negative) to +1 (very positive)
score = float64(positiveScore-negativeScore) / float64(totalScore)
if score > 0.2 {
sentiment = SentimentPositive
} else if score < -0.2 {
sentiment = SentimentNegative
} else {
sentiment = SentimentNeutral
}
return sentiment, score
}
// isNewsCritical checks if news contains critical/alert-worthy content
func isNewsCritical(title, abstract string) bool {
text := strings.ToLower(title + " " + abstract)
for _, keyword := range criticalKeywords {
if strings.Contains(text, keyword) {
return true
}
}
return false
}

97
msn/rate_limiter.go Normal file
View file

@ -0,0 +1,97 @@
package msn
import (
"math/rand"
"sync"
"time"
)
// RateLimiter implements a token bucket rate limiter with random delay
type RateLimiter struct {
mu sync.Mutex
tokens float64
maxTokens float64
refillRate float64 // tokens per second
lastRefill time.Time
minDelayMs int // minimum delay in milliseconds
maxDelayMs int // maximum delay in milliseconds
requestCount int64
}
// RateLimiterConfig holds rate limiter configuration
type RateLimiterConfig struct {
RequestsPerSecond float64 // target RPS
MinDelayMs int // minimum random delay
MaxDelayMs int // maximum random delay
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(config RateLimiterConfig) *RateLimiter {
if config.RequestsPerSecond <= 0 {
config.RequestsPerSecond = 10 // default 10 RPS
}
return &RateLimiter{
tokens: config.RequestsPerSecond, // start with full bucket
maxTokens: config.RequestsPerSecond,
refillRate: config.RequestsPerSecond,
lastRefill: time.Now(),
minDelayMs: config.MinDelayMs,
maxDelayMs: config.MaxDelayMs,
}
}
// Wait blocks until a token is available and applies random delay
func (r *RateLimiter) Wait() {
r.mu.Lock()
defer r.mu.Unlock()
// Refill tokens based on elapsed time
now := time.Now()
elapsed := now.Sub(r.lastRefill).Seconds()
r.tokens += elapsed * r.refillRate
if r.tokens > r.maxTokens {
r.tokens = r.maxTokens
}
r.lastRefill = now
// Wait if no tokens available
if r.tokens < 1 {
waitTime := time.Duration((1-r.tokens)/r.refillRate*1000) * time.Millisecond
r.mu.Unlock()
time.Sleep(waitTime)
r.mu.Lock()
r.tokens = 0
} else {
r.tokens--
}
r.requestCount++
// Apply random delay if configured
if r.maxDelayMs > 0 {
delayRange := r.maxDelayMs - r.minDelayMs
if delayRange <= 0 {
delayRange = 1
}
delay := r.minDelayMs + rand.Intn(delayRange)
r.mu.Unlock()
time.Sleep(time.Duration(delay) * time.Millisecond)
r.mu.Lock()
}
}
// RequestCount returns the total number of requests made
func (r *RateLimiter) RequestCount() int64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.requestCount
}
// SetRPS dynamically adjusts the rate limit
func (r *RateLimiter) SetRPS(rps float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.maxTokens = rps
r.refillRate = rps
}