diff --git a/cmd/longbridge-fs/main.go b/cmd/longbridge-fs/main.go index 3edcd88..7d8c25f 100644 --- a/cmd/longbridge-fs/main.go +++ b/cmd/longbridge-fs/main.go @@ -15,6 +15,8 @@ import ( "longbridge-fs/internal/credential" "longbridge-fs/internal/ledger" "longbridge-fs/internal/market" + "longbridge-fs/internal/portfolio" + "longbridge-fs/internal/research" "longbridge-fs/internal/risk" "github.com/longbridge/openapi-go/quote" @@ -526,6 +528,18 @@ func runController(root string, interval time.Duration, credFile string, mock bo market.RefreshQuotes(ctx, qc, root) } + // Phase 3: Refresh research feeds (news/topics from Content API) + if !useMock { + if err := research.RefreshFeeds(ctx, root, credFile); err != nil { + // Don't fail the entire cycle for research refresh errors + if verbose { + log.Printf("⚠ Research refresh failed: %v", err) + } + } else if verbose { + log.Printf("✓ Research feeds refreshed") + } + } + // Generate PnL report (positions + current prices — file-only, works in mock) if err := account.GeneratePnL(root); err != nil { log.Printf("❌ PnL generation failed: %v", err) @@ -540,6 +554,27 @@ func runController(root string, interval time.Duration, credFile string, mock bo log.Printf("✓ Portfolio summary generated") } + // Phase 2: Portfolio construction - sync current portfolio state + if err := portfolio.SyncCurrent(root); err != nil { + log.Printf("❌ Portfolio sync failed: %v", err) + } else if verbose { + log.Printf("✓ Portfolio current state synced") + } + + // Phase 2: Compute portfolio diff (target vs current) + if err := portfolio.ComputeDiff(root); err != nil { + log.Printf("❌ Portfolio diff computation failed: %v", err) + } else if verbose { + log.Printf("✓ Portfolio diff computed") + } + + // Phase 2: Process pending rebalance orders + if err := portfolio.ProcessRebalance(root); err != nil { + log.Printf("❌ Rebalance processing failed: %v", err) + } else if verbose { + log.Printf("✓ Rebalance processed") + } + // Risk control: stop-loss / take-profit if err := risk.CheckRiskRules(root); err != nil { log.Printf("❌ Risk check failed: %v", err) diff --git a/internal/model/types.go b/internal/model/types.go index 0ed4d63..602f9f8 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -249,3 +249,136 @@ type OrderMetadata struct { SignalRefs []string `json:"signal_refs,omitempty"` // triggering signals } +// --- Phase 2: Portfolio Construction types --- + +// TargetPortfolio is the JSON structure for /portfolio/target.json +type TargetPortfolio struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by"` + Strategy string `json:"strategy"` + TotalCapitalPct float64 `json:"total_capital_pct"` + Positions map[string]TargetPosition `json:"positions"` + CashReservePct float64 `json:"cash_reserve_pct"` +} + +// TargetPosition defines target allocation for a symbol +type TargetPosition struct { + Weight float64 `json:"weight"` + Reason string `json:"reason"` + SignalRefs []string `json:"signal_refs"` +} + +// CurrentPortfolio is the JSON structure for /portfolio/current.json +type CurrentPortfolio struct { + UpdatedAt string `json:"updated_at"` + TotalEquity float64 `json:"total_equity"` + Positions map[string]CurrentPosition `json:"positions"` + Cash float64 `json:"cash"` + CashPct float64 `json:"cash_pct"` +} + +// CurrentPosition represents current holding with market value +type CurrentPosition struct { + Qty float64 `json:"qty"` + MarketValue float64 `json:"market_value"` + Weight float64 `json:"weight"` + AvgCost float64 `json:"avg_cost"` +} + +// PortfolioDiff is the JSON structure for /portfolio/diff.json +type PortfolioDiff struct { + ComputedAt string `json:"computed_at"` + TargetVersion int `json:"target_version"` + Adjustments []Adjustment `json:"adjustments"` + RequiresRebalance bool `json:"requires_rebalance"` +} + +// Adjustment describes a required portfolio adjustment +type Adjustment struct { + Symbol string `json:"symbol"` + CurrentWeight float64 `json:"current_weight"` + TargetWeight float64 `json:"target_weight"` + Action string `json:"action"` // ADD, REDUCE, CLOSE, HOLD + DeltaQty int64 `json:"delta_qty"` + DeltaValue float64 `json:"delta_value"` + EstimatedSide string `json:"estimated_side"` // BUY, SELL + EstimatedQty int64 `json:"estimated_qty"` +} + +// RebalancePending is the JSON structure for /portfolio/rebalance/pending.json +type RebalancePending struct { + RebalanceID string `json:"rebalance_id"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` + AutoExecute bool `json:"auto_execute"` + Orders []RebalanceOrder `json:"orders"` +} + +// RebalanceOrder defines an order in a rebalance operation +type RebalanceOrder struct { + Symbol string `json:"symbol"` + Side string `json:"side"` + Qty int64 `json:"qty"` + Type string `json:"type"` + Price float64 `json:"price,omitempty"` + TIF string `json:"tif"` +} + +// --- Phase 3: Research & Signal types --- + +// Watchlist is the JSON structure for /research/watchlist.json +type Watchlist struct { + Symbols []string `json:"symbols"` + RefreshInterval string `json:"refresh_interval"` // e.g., "5m" + Feeds []string `json:"feeds"` // e.g., ["news", "topics"] +} + +// NewsFeed is the JSON structure for /research/feeds/news/{SYMBOL}/latest.json +type NewsFeed struct { + Symbol string `json:"symbol"` + FetchedAt string `json:"fetched_at"` + Items []NewsItem `json:"items"` +} + +// NewsItem represents a single news article +type NewsItem struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + PublishedAt string `json:"published_at"` + Summary string `json:"summary"` + URL string `json:"url,omitempty"` +} + +// TopicsFeed is the JSON structure for /research/feeds/topics/{SYMBOL}/latest.json +type TopicsFeed struct { + Symbol string `json:"symbol"` + FetchedAt string `json:"fetched_at"` + Items []TopicItem `json:"items"` +} + +// TopicItem represents a discussion topic +type TopicItem struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + PublishedAt string `json:"published_at"` + Summary string `json:"summary"` + URL string `json:"url,omitempty"` +} + +// ResearchSummary is the JSON structure for /research/summary.json +type ResearchSummary struct { + UpdatedAt string `json:"updated_at"` + Symbols map[string]SymbolResearch `json:"symbols"` +} + +// SymbolResearch tracks available research data for a symbol +type SymbolResearch struct { + HasQuote bool `json:"has_quote"` + HasNews bool `json:"has_news"` + HasTopics bool `json:"has_topics"` + CustomFeeds []string `json:"custom_feeds"` +} + diff --git a/internal/portfolio/diff.go b/internal/portfolio/diff.go new file mode 100644 index 0000000..5ee442e --- /dev/null +++ b/internal/portfolio/diff.go @@ -0,0 +1,163 @@ +package portfolio + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "time" + + "longbridge-fs/internal/model" +) + +// ComputeDiff calculates the difference between target and current portfolios +// and writes the result to portfolio/diff.json +func ComputeDiff(root string) error { + // Read target portfolio + target, err := ParseTarget(root) + if err != nil { + // If target doesn't exist, skip diff calculation + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read target portfolio: %w", err) + } + + // Read current portfolio + currentPath := filepath.Join(root, "portfolio", "current.json") + currentData, err := os.ReadFile(currentPath) + if err != nil { + return fmt.Errorf("read current portfolio: %w", err) + } + + var current model.CurrentPortfolio + if err := json.Unmarshal(currentData, ¤t); err != nil { + return fmt.Errorf("parse current portfolio: %w", err) + } + + // Calculate diff + diff := model.PortfolioDiff{ + ComputedAt: time.Now().UTC().Format(time.RFC3339), + TargetVersion: target.Version, + Adjustments: []model.Adjustment{}, + } + + // Build maps for easier lookup + currentWeights := make(map[string]float64) + for symbol, pos := range current.Positions { + currentWeights[symbol] = pos.Weight + } + + // Track all symbols (both target and current) + allSymbols := make(map[string]bool) + for symbol := range target.Positions { + allSymbols[symbol] = true + } + for symbol := range current.Positions { + allSymbols[symbol] = true + } + + // Calculate adjustments for each symbol + for symbol := range allSymbols { + targetPos, hasTarget := target.Positions[symbol] + currentPos, hasCurrent := current.Positions[symbol] + + targetWeight := 0.0 + if hasTarget { + targetWeight = targetPos.Weight * target.TotalCapitalPct + } + + currentWeight := 0.0 + if hasCurrent { + currentWeight = currentPos.Weight + } + + // Calculate target value and quantity + targetValue := targetWeight * current.TotalEquity + currentValue := currentWeight * current.TotalEquity + + deltaValue := targetValue - currentValue + + // Skip if difference is negligible (less than 1% of position or $100) + threshold := math.Max(100, math.Abs(currentValue)*0.01) + if math.Abs(deltaValue) < threshold && hasCurrent && hasTarget { + continue + } + + // Determine action + action := "HOLD" + estimatedSide := "" + estimatedQty := int64(0) + deltaQty := int64(0) + + if !hasTarget && hasCurrent { + action = "CLOSE" + estimatedSide = "SELL" + estimatedQty = int64(currentPos.Qty) + deltaQty = -int64(currentPos.Qty) + } else if hasTarget && !hasCurrent { + action = "ADD" + estimatedSide = "BUY" + // Estimate quantity based on current price + if currentPrice := getSymbolPrice(root, symbol); currentPrice > 0 { + estimatedQty = int64(targetValue / currentPrice) + deltaQty = estimatedQty + } + } else if hasTarget && hasCurrent { + if deltaValue > 0 { + action = "REDUCE" + estimatedSide = "BUY" + if currentPrice := getSymbolPrice(root, symbol); currentPrice > 0 { + estimatedQty = int64(deltaValue / currentPrice) + deltaQty = estimatedQty + } + } else { + action = "REDUCE" + estimatedSide = "SELL" + if currentPrice := getSymbolPrice(root, symbol); currentPrice > 0 { + estimatedQty = int64(-deltaValue / currentPrice) + deltaQty = -estimatedQty + } + } + } + + if action != "HOLD" { + diff.Adjustments = append(diff.Adjustments, model.Adjustment{ + Symbol: symbol, + CurrentWeight: currentWeight, + TargetWeight: targetWeight, + Action: action, + DeltaQty: deltaQty, + DeltaValue: deltaValue, + EstimatedSide: estimatedSide, + EstimatedQty: estimatedQty, + }) + diff.RequiresRebalance = true + } + } + + // Write diff.json + data, err := json.MarshalIndent(diff, "", " ") + if err != nil { + return fmt.Errorf("marshal diff: %w", err) + } + + return os.WriteFile(filepath.Join(root, "portfolio", "diff.json"), append(data, '\n'), 0644) +} + +// getSymbolPrice retrieves current price from quote/hold/{SYMBOL}/overview.json +func getSymbolPrice(root, symbol string) float64 { + overviewPath := filepath.Join(root, "quote", "hold", symbol, "overview.json") + data, err := os.ReadFile(overviewPath) + if err != nil { + return 0 + } + + var overview model.QuoteOverview + if err := json.Unmarshal(data, &overview); err != nil { + return 0 + } + + return overview.Last +} diff --git a/internal/portfolio/portfolio.go b/internal/portfolio/portfolio.go new file mode 100644 index 0000000..7fec97e --- /dev/null +++ b/internal/portfolio/portfolio.go @@ -0,0 +1,105 @@ +package portfolio + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "longbridge-fs/internal/model" +) + +// SyncCurrent reads account/state.json and quote/hold/*/overview.json +// to generate portfolio/current.json with position weights and market values. +func SyncCurrent(root string) error { + // Read account state + stateData, err := os.ReadFile(filepath.Join(root, "account", "state.json")) + if err != nil { + return fmt.Errorf("read account state: %w", err) + } + var state model.AccountState + if err := json.Unmarshal(stateData, &state); err != nil { + return fmt.Errorf("parse account state: %w", err) + } + + // Calculate total equity + var totalCash float64 + for _, c := range state.Cash { + totalCash += c.Available + } + + current := model.CurrentPortfolio{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Positions: make(map[string]model.CurrentPosition), + Cash: totalCash, + } + + var totalPositionValue float64 + + // Process each position + for _, pos := range state.Positions { + qty := parseFloat(pos.Quantity) + if qty == 0 { + continue + } + + // Look up current price + overviewPath := filepath.Join(root, "quote", "hold", pos.Symbol, "overview.json") + overviewData, err := os.ReadFile(overviewPath) + if err != nil { + // Skip if no quote available + continue + } + + var overview model.QuoteOverview + if err := json.Unmarshal(overviewData, &overview); err != nil { + continue + } + + marketValue := qty * overview.Last + totalPositionValue += marketValue + + current.Positions[pos.Symbol] = model.CurrentPosition{ + Qty: qty, + MarketValue: marketValue, + Weight: 0, // will be calculated after total equity is known + AvgCost: pos.CostPrice, + } + } + + current.TotalEquity = totalCash + totalPositionValue + current.CashPct = 0 + if current.TotalEquity > 0 { + current.CashPct = totalCash / current.TotalEquity + } + + // Calculate weights + for symbol, pos := range current.Positions { + pos.Weight = 0 + if current.TotalEquity > 0 { + pos.Weight = pos.MarketValue / current.TotalEquity + } + current.Positions[symbol] = pos + } + + // Write portfolio/current.json + data, err := json.MarshalIndent(current, "", " ") + if err != nil { + return fmt.Errorf("marshal current portfolio: %w", err) + } + + portfolioDir := filepath.Join(root, "portfolio") + if err := os.MkdirAll(portfolioDir, 0755); err != nil { + return fmt.Errorf("create portfolio dir: %w", err) + } + + return os.WriteFile(filepath.Join(portfolioDir, "current.json"), append(data, '\n'), 0644) +} + +// parseFloat converts string quantity to float64 +func parseFloat(s string) float64 { + f, _ := strconv.ParseFloat(s, 64) + return f +} diff --git a/internal/portfolio/rebalance.go b/internal/portfolio/rebalance.go new file mode 100644 index 0000000..c30720c --- /dev/null +++ b/internal/portfolio/rebalance.go @@ -0,0 +1,129 @@ +package portfolio + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "longbridge-fs/internal/model" +) + +// ProcessRebalance reads portfolio/rebalance/pending.json and converts it to ORDER entries +// in trade/beancount.txt, then archives the pending file to history/ +func ProcessRebalance(root string) error { + pendingPath := filepath.Join(root, "portfolio", "rebalance", "pending.json") + + // Check if pending.json exists + data, err := os.ReadFile(pendingPath) + if err != nil { + if os.IsNotExist(err) { + return nil // No pending rebalance + } + return fmt.Errorf("read pending rebalance: %w", err) + } + + var pending model.RebalancePending + if err := json.Unmarshal(data, &pending); err != nil { + return fmt.Errorf("parse pending rebalance: %w", err) + } + + // Convert to ORDER entries + if err := writeRebalanceOrders(root, &pending); err != nil { + return fmt.Errorf("write rebalance orders: %w", err) + } + + // Archive pending.json to history + if err := archivePending(root, &pending); err != nil { + return fmt.Errorf("archive pending: %w", err) + } + + // Remove pending.json + if err := os.Remove(pendingPath); err != nil { + return fmt.Errorf("remove pending: %w", err) + } + + return nil +} + +// writeRebalanceOrders appends ORDER entries to trade/beancount.txt +func writeRebalanceOrders(root string, pending *model.RebalancePending) error { + beancountPath := filepath.Join(root, "trade", "beancount.txt") + + f, err := os.OpenFile(beancountPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + timestamp := time.Now().UTC().Format("2006-01-02") + + for i, order := range pending.Orders { + intentID := fmt.Sprintf("%s-%03d", strings.ReplaceAll(pending.RebalanceID, "rebal-", ""), i+1) + + var orderLines strings.Builder + orderLines.WriteString("\n") + orderLines.WriteString(fmt.Sprintf("%s * \"ORDER\" \"%s %s %d %s\"\n", + timestamp, order.Side, order.Qty, order.Symbol, pending.RebalanceID)) + orderLines.WriteString(fmt.Sprintf(" ; intent_id: %s\n", intentID)) + orderLines.WriteString(fmt.Sprintf(" ; side: %s\n", order.Side)) + orderLines.WriteString(fmt.Sprintf(" ; symbol: %s\n", order.Symbol)) + orderLines.WriteString(fmt.Sprintf(" ; qty: %d\n", order.Qty)) + orderLines.WriteString(fmt.Sprintf(" ; type: %s\n", order.Type)) + if order.Price > 0 { + orderLines.WriteString(fmt.Sprintf(" ; price: %.2f\n", order.Price)) + } + orderLines.WriteString(fmt.Sprintf(" ; tif: %s\n", order.TIF)) + orderLines.WriteString(" ; source: rebalance\n") + orderLines.WriteString(fmt.Sprintf(" ; rebalance_id: %s\n", pending.RebalanceID)) + + if _, err := f.WriteString(orderLines.String()); err != nil { + return err + } + } + + return nil +} + +// archivePending saves the pending rebalance to history with timestamp +func archivePending(root string, pending *model.RebalancePending) error { + historyDir := filepath.Join(root, "portfolio", "history") + if err := os.MkdirAll(historyDir, 0755); err != nil { + return err + } + + timestamp := time.Now().UTC().Format("20060102-150405") + filename := fmt.Sprintf("%s-%s.json", timestamp, pending.RebalanceID) + historyPath := filepath.Join(historyDir, filename) + + data, err := json.MarshalIndent(pending, "", " ") + if err != nil { + return err + } + + return os.WriteFile(historyPath, append(data, '\n'), 0644) +} + +// ArchiveTarget saves the current target.json to history/ with a timestamp +func ArchiveTarget(root string) error { + targetPath := filepath.Join(root, "portfolio", "target.json") + data, err := os.ReadFile(targetPath) + if err != nil { + if os.IsNotExist(err) { + return nil // No target to archive + } + return err + } + + historyDir := filepath.Join(root, "portfolio", "history") + if err := os.MkdirAll(historyDir, 0755); err != nil { + return err + } + + timestamp := time.Now().UTC().Format("20060102-150405") + historyPath := filepath.Join(historyDir, fmt.Sprintf("%s-target.json", timestamp)) + + return os.WriteFile(historyPath, data, 0644) +} diff --git a/internal/portfolio/target.go b/internal/portfolio/target.go new file mode 100644 index 0000000..5caae5f --- /dev/null +++ b/internal/portfolio/target.go @@ -0,0 +1,66 @@ +package portfolio + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "longbridge-fs/internal/model" +) + +// ParseTarget reads and validates portfolio/target.json +func ParseTarget(root string) (*model.TargetPortfolio, error) { + targetPath := filepath.Join(root, "portfolio", "target.json") + data, err := os.ReadFile(targetPath) + if err != nil { + return nil, err + } + + var target model.TargetPortfolio + if err := json.Unmarshal(data, &target); err != nil { + return nil, fmt.Errorf("parse target portfolio: %w", err) + } + + if err := ValidateTarget(&target); err != nil { + return nil, fmt.Errorf("invalid target portfolio: %w", err) + } + + return &target, nil +} + +// ValidateTarget ensures target portfolio has valid weights +func ValidateTarget(target *model.TargetPortfolio) error { + if target.Version < 1 { + return fmt.Errorf("version must be >= 1") + } + + if target.TotalCapitalPct < 0 || target.TotalCapitalPct > 1 { + return fmt.Errorf("total_capital_pct must be between 0 and 1, got %f", target.TotalCapitalPct) + } + + if target.CashReservePct < 0 || target.CashReservePct > 1 { + return fmt.Errorf("cash_reserve_pct must be between 0 and 1, got %f", target.CashReservePct) + } + + // Validate that total_capital_pct + cash_reserve_pct = 1.0 (approximately) + total := target.TotalCapitalPct + target.CashReservePct + if total < 0.99 || total > 1.01 { + return fmt.Errorf("total_capital_pct + cash_reserve_pct should equal 1.0, got %f", total) + } + + // Validate position weights sum to 1.0 + var weightSum float64 + for symbol, pos := range target.Positions { + if pos.Weight < 0 || pos.Weight > 1 { + return fmt.Errorf("weight for %s must be between 0 and 1, got %f", symbol, pos.Weight) + } + weightSum += pos.Weight + } + + if weightSum < 0.99 || weightSum > 1.01 { + return fmt.Errorf("position weights should sum to 1.0, got %f", weightSum) + } + + return nil +} diff --git a/internal/research/research.go b/internal/research/research.go new file mode 100644 index 0000000..764a8ea --- /dev/null +++ b/internal/research/research.go @@ -0,0 +1,135 @@ +package research + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "longbridge-fs/internal/credential" + "longbridge-fs/internal/model" + + "github.com/longbridge/openapi-go/content" +) + +// RefreshFeeds reads the watchlist and refreshes news/topics feeds from Content API +func RefreshFeeds(ctx context.Context, root, credFile string) error { + // Parse watchlist + watchlist, err := ParseWatchlist(root) + if err != nil { + if os.IsNotExist(err) { + return nil // No watchlist, skip refresh + } + return fmt.Errorf("parse watchlist: %w", err) + } + + // Load credentials + cfg, err := credential.Load(credFile) + if err != nil { + return fmt.Errorf("load credentials: %w", err) + } + + // Create content context + cc, err := content.NewFromCfg(cfg) + if err != nil { + return fmt.Errorf("create content context: %w", err) + } + + // Refresh feeds for each symbol + for _, symbol := range watchlist.Symbols { + for _, feed := range watchlist.Feeds { + switch feed { + case "news": + if err := refreshNews(ctx, cc, root, symbol); err != nil { + // Log error but continue with other symbols + fmt.Printf("Warning: failed to refresh news for %s: %v\n", symbol, err) + } + case "topics": + if err := refreshTopics(ctx, cc, root, symbol); err != nil { + fmt.Printf("Warning: failed to refresh topics for %s: %v\n", symbol, err) + } + } + } + } + + // Generate summary + return GenerateSummary(root) +} + +// refreshNews fetches and writes news feed for a symbol +func refreshNews(ctx context.Context, cc *content.ContentContext, root, symbol string) error { + newsItems, err := cc.News(ctx, symbol) + if err != nil { + return err + } + + feed := model.NewsFeed{ + Symbol: symbol, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + Items: make([]model.NewsItem, 0, len(newsItems)), + } + + for _, item := range newsItems { + feed.Items = append(feed.Items, model.NewsItem{ + ID: item.Id, + Title: item.Title, + Source: "longbridge", // Content API doesn't provide source in the type + PublishedAt: item.PublishedAt.Format(time.RFC3339), + Summary: item.Description, + URL: item.Url, + }) + } + + // Write to feeds/news/{SYMBOL}/latest.json + newsDir := filepath.Join(root, "research", "feeds", "news", symbol) + if err := os.MkdirAll(newsDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(feed, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(newsDir, "latest.json"), append(data, '\n'), 0644) +} + +// refreshTopics fetches and writes topics feed for a symbol +func refreshTopics(ctx context.Context, cc *content.ContentContext, root, symbol string) error { + topicItems, err := cc.Topics(ctx, symbol) + if err != nil { + return err + } + + feed := model.TopicsFeed{ + Symbol: symbol, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + Items: make([]model.TopicItem, 0, len(topicItems)), + } + + for _, item := range topicItems { + feed.Items = append(feed.Items, model.TopicItem{ + ID: item.Id, + Title: item.Title, + Source: "longbridge", // Content API doesn't provide source in the type + PublishedAt: item.PublishedAt.Format(time.RFC3339), + Summary: item.Description, + URL: item.Url, + }) + } + + // Write to feeds/topics/{SYMBOL}/latest.json + topicsDir := filepath.Join(root, "research", "feeds", "topics", symbol) + if err := os.MkdirAll(topicsDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(feed, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(topicsDir, "latest.json"), append(data, '\n'), 0644) +} diff --git a/internal/research/summary.go b/internal/research/summary.go new file mode 100644 index 0000000..2a335c7 --- /dev/null +++ b/internal/research/summary.go @@ -0,0 +1,90 @@ +package research + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "longbridge-fs/internal/model" +) + +// GenerateSummary scans research feeds and generates summary.json +func GenerateSummary(root string) error { + summary := model.ResearchSummary{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Symbols: make(map[string]model.SymbolResearch), + } + + // Scan news feeds + newsPath := filepath.Join(root, "research", "feeds", "news") + if entries, err := os.ReadDir(newsPath); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + symbol := entry.Name() + latest := filepath.Join(newsPath, symbol, "latest.json") + if _, err := os.Stat(latest); err == nil { + sr := summary.Symbols[symbol] + sr.HasNews = true + summary.Symbols[symbol] = sr + } + } + } + + // Scan topics feeds + topicsPath := filepath.Join(root, "research", "feeds", "topics") + if entries, err := os.ReadDir(topicsPath); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + symbol := entry.Name() + latest := filepath.Join(topicsPath, symbol, "latest.json") + if _, err := os.Stat(latest); err == nil { + sr := summary.Symbols[symbol] + sr.HasTopics = true + summary.Symbols[symbol] = sr + } + } + } + + // Check quote availability + quotePath := filepath.Join(root, "quote", "hold") + if entries, err := os.ReadDir(quotePath); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + symbol := entry.Name() + overview := filepath.Join(quotePath, symbol, "overview.json") + if _, err := os.Stat(overview); err == nil { + sr := summary.Symbols[symbol] + sr.HasQuote = true + summary.Symbols[symbol] = sr + } + } + } + + // Scan custom feeds + customPath := filepath.Join(root, "research", "feeds", "custom") + if entries, err := os.ReadDir(customPath); err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + // Custom feeds are tracked globally, not per-symbol + // For simplicity, we'll just note their existence + // Could enhance this to parse and extract symbol references + } + } + + // Write summary + data, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(root, "research", "summary.json"), append(data, '\n'), 0644) +} diff --git a/internal/research/watchlist.go b/internal/research/watchlist.go new file mode 100644 index 0000000..7cc25b1 --- /dev/null +++ b/internal/research/watchlist.go @@ -0,0 +1,42 @@ +package research + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "longbridge-fs/internal/model" +) + +// ParseWatchlist reads and parses research/watchlist.json +func ParseWatchlist(root string) (*model.Watchlist, error) { + watchlistPath := filepath.Join(root, "research", "watchlist.json") + data, err := os.ReadFile(watchlistPath) + if err != nil { + return nil, err + } + + var watchlist model.Watchlist + if err := json.Unmarshal(data, &watchlist); err != nil { + return nil, fmt.Errorf("parse watchlist: %w", err) + } + + return &watchlist, nil +} + +// WriteWatchlist writes a watchlist to research/watchlist.json +func WriteWatchlist(root string, watchlist *model.Watchlist) error { + researchDir := filepath.Join(root, "research") + if err := os.MkdirAll(researchDir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(watchlist, "", " ") + if err != nil { + return err + } + + watchlistPath := filepath.Join(researchDir, "watchlist.json") + return os.WriteFile(watchlistPath, append(data, '\n'), 0644) +} diff --git a/task.md b/task.md index 37a18f6..e6008c1 100644 --- a/task.md +++ b/task.md @@ -22,16 +22,16 @@ 目标:信号到 rebalance 的完整链路 -- [ ] `portfolio/target.json` → `current.json` → `diff.json` 计算 -- [ ] `rebalance/pending.json` → ORDER 转化 -- [ ] `portfolio/history/` 快照归档 +- [x] `portfolio/target.json` → `current.json` → `diff.json` 计算 +- [x] `rebalance/pending.json` → ORDER 转化 +- [x] `portfolio/history/` 快照归档 - [ ] `--auto-rebalance` 模式 ### Phase 3: 研究与信号 (v0.5.0) 目标:数据管道自动化 -- [ ] `research/watchlist.json` + Content API 集成 +- [x] `research/watchlist.json` + Content API 集成 - [ ] `signal/definitions/` 声明式配置 - [ ] 内置指标计算 (SMA_CROSS, RSI, PRICE_CHANGE) - [ ] `signal/active.json` 聚合