Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions cmd/longbridge-fs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
133 changes: 133 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

163 changes: 163 additions & 0 deletions internal/portfolio/diff.go
Original file line number Diff line number Diff line change
@@ -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, &current); 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
}
Loading
Loading