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
36 changes: 29 additions & 7 deletions cmd/longbridge-fs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"longbridge-fs/internal/portfolio"
"longbridge-fs/internal/research"
"longbridge-fs/internal/risk"
signalpkg "longbridge-fs/internal/signal"

"github.com/longbridge/openapi-go/quote"
"github.com/longbridge/openapi-go/trade"
Expand Down Expand Up @@ -371,11 +372,12 @@ func runInit(root string) error {
// controllerCmd creates the controller subcommand
func controllerCmd() *cobra.Command {
var (
root string
interval time.Duration
credFile string
mock bool
compactAfter int
root string
interval time.Duration
credFile string
mock bool
compactAfter int
autoRebalance bool
)

cmd := &cobra.Command{
Expand All @@ -399,7 +401,7 @@ The controller monitors the file system and automatically:
# Custom polling interval
longbridge-fs controller --root ./fs --interval 5s`,
RunE: func(cmd *cobra.Command, args []string) error {
return runController(root, interval, credFile, mock, compactAfter)
return runController(root, interval, credFile, mock, compactAfter, autoRebalance)
},
}

Expand All @@ -408,11 +410,12 @@ The controller monitors the file system and automatically:
cmd.Flags().StringVar(&credFile, "credential", "credential", "Credential file path")
cmd.Flags().BoolVar(&mock, "mock", false, "Use mock execution without API")
cmd.Flags().IntVar(&compactAfter, "compact-after", 10, "Compact after N executed orders, 0=disable")
cmd.Flags().BoolVar(&autoRebalance, "auto-rebalance", false, "Automatically create rebalance orders when portfolio drift is detected")

return cmd
}

func runController(root string, interval time.Duration, credFile string, mock bool, compactAfter int) error {
func runController(root string, interval time.Duration, credFile string, mock bool, compactAfter int, autoRebalance bool) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Expand Down Expand Up @@ -475,6 +478,7 @@ func runController(root string, interval time.Duration, credFile string, mock bo
log.Printf(" Interval: %s", interval)
log.Printf(" Compact after: %d orders", compactAfter)
log.Printf(" Mock mode: %v", useMock)
log.Printf(" Auto-rebalance: %v", autoRebalance)
}

log.Printf("🚀 Controller started (interval=%s, compact-after=%d)", interval, compactAfter)
Expand Down Expand Up @@ -540,6 +544,15 @@ func runController(root string, interval time.Duration, credFile string, mock bo
}
}

// Phase 3: Compute builtin signals from signal/definitions/
if err := signalpkg.ComputeAll(root); err != nil {
if verbose {
log.Printf("⚠ Signal computation failed: %v", err)
}
} else if verbose {
log.Printf("✓ Signals computed")
}

// 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 Down Expand Up @@ -568,6 +581,15 @@ func runController(root string, interval time.Duration, credFile string, mock bo
log.Printf("✓ Portfolio diff computed")
}

// Phase 2: Auto-rebalance mode: create pending.json from diff when drift detected
if autoRebalance {
if err := portfolio.AutoCreatePending(root); err != nil {
log.Printf("❌ Auto-rebalance failed: %v", err)
} else if verbose {
log.Printf("✓ Auto-rebalance check complete")
}
}

// Phase 2: Process pending rebalance orders
if err := portfolio.ProcessRebalance(root); err != nil {
log.Printf("❌ Rebalance processing failed: %v", err)
Expand Down
43 changes: 43 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,49 @@ type RebalanceOrder struct {
TIF string `json:"tif"`
}

// --- Phase 3: Signal types ---

// SignalDefinition is the JSON structure for /signal/definitions/{name}.json
type SignalDefinition struct {
Name string `json:"name"`
Type string `json:"type"` // builtin, external
Version int `json:"version"`
Params map[string]interface{} `json:"params,omitempty"` // indicator-specific params
Symbols []string `json:"symbols"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
}

// SignalOutput is the JSON structure for /signal/output/{SYMBOL}/latest.json
type SignalOutput struct {
Symbol string `json:"symbol"`
UpdatedAt string `json:"updated_at"`
Signals []SignalEntry `json:"signals"`
}

// SignalEntry represents a computed signal value
type SignalEntry struct {
Name string `json:"name"`
Value string `json:"value"` // BULLISH, BEARISH, NEUTRAL, POSITIVE, NEGATIVE
Strength float64 `json:"strength"` // 0.0 ~ 1.0
Detail string `json:"detail"`
ComputedAt string `json:"computed_at"`
}

// ActiveSignals is the JSON structure for /signal/active.json
type ActiveSignals struct {
UpdatedAt string `json:"updated_at"`
Signals []ActiveSignalEntry `json:"signals"`
}

// ActiveSignalEntry is one active signal in the aggregated view
type ActiveSignalEntry struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Value string `json:"value"`
Strength float64 `json:"strength"`
}

// --- Phase 3: Research & Signal types ---

// Watchlist is the JSON structure for /research/watchlist.json
Expand Down
75 changes: 74 additions & 1 deletion internal/portfolio/rebalance.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,79 @@ import (
"longbridge-fs/internal/model"
)

// AutoCreatePending reads portfolio/diff.json and, when requires_rebalance is true,
// automatically generates portfolio/rebalance/pending.json so the controller can
// process it on the next cycle. It is a no-op when pending.json already exists.
func AutoCreatePending(root string) error {
// Read diff.json
diffPath := filepath.Join(root, "portfolio", "diff.json")
data, err := os.ReadFile(diffPath)
if err != nil {
if os.IsNotExist(err) {
return nil // No diff available
}
return fmt.Errorf("read diff: %w", err)
}

var diff model.PortfolioDiff
if err := json.Unmarshal(data, &diff); err != nil {
return fmt.Errorf("parse diff: %w", err)
}

if !diff.RequiresRebalance || len(diff.Adjustments) == 0 {
return nil // Nothing to do
}

// Don't overwrite an existing pending.json
pendingPath := filepath.Join(root, "portfolio", "rebalance", "pending.json")
if _, err := os.Stat(pendingPath); err == nil {
return nil // Already pending
}

// Convert diff adjustments to rebalance orders
rebalanceID := fmt.Sprintf("rebal-%s", time.Now().UTC().Format("20060102-150405"))
pending := model.RebalancePending{
RebalanceID: rebalanceID,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
CreatedBy: "controller-auto-rebalance",
AutoExecute: true,
Orders: make([]model.RebalanceOrder, 0, len(diff.Adjustments)),
}

for _, adj := range diff.Adjustments {
if adj.EstimatedQty <= 0 {
continue
}

order := model.RebalanceOrder{
Symbol: adj.Symbol,
Side: adj.EstimatedSide,
Qty: adj.EstimatedQty,
Type: "MARKET",
TIF: "DAY",
}

pending.Orders = append(pending.Orders, order)
}

if len(pending.Orders) == 0 {
return nil
}

// Write pending.json
rebalDir := filepath.Join(root, "portfolio", "rebalance")
if err := os.MkdirAll(rebalDir, 0755); err != nil {
return fmt.Errorf("create rebalance dir: %w", err)
}

out, err := json.MarshalIndent(pending, "", " ")
if err != nil {
return fmt.Errorf("marshal pending: %w", err)
}

return os.WriteFile(pendingPath, append(out, '\n'), 0644)
}

// 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 {
Expand Down Expand Up @@ -65,7 +138,7 @@ func writeRebalanceOrders(root string, pending *model.RebalancePending) error {

var orderLines strings.Builder
orderLines.WriteString("\n")
orderLines.WriteString(fmt.Sprintf("%s * \"ORDER\" \"%s %s %d %s\"\n",
orderLines.WriteString(fmt.Sprintf("%s * \"ORDER\" \"%s %d %s %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))
Expand Down
94 changes: 94 additions & 0 deletions internal/signal/definitions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package signal

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"longbridge-fs/internal/model"
)

// ListDefinitions scans signal/definitions/ and returns all enabled signal definitions.
func ListDefinitions(root string) ([]*model.SignalDefinition, error) {
defsDir := filepath.Join(root, "signal", "definitions")

entries, err := os.ReadDir(defsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read definitions dir: %w", err)
}

var defs []*model.SignalDefinition
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}

def, err := ParseDefinition(filepath.Join(defsDir, entry.Name()))
if err != nil {
// Skip invalid definitions but log
fmt.Printf("Warning: skipping invalid signal definition %s: %v\n", entry.Name(), err)
continue
}

if def.Enabled {
defs = append(defs, def)
}
}

return defs, nil
}

// ParseDefinition reads and parses a single signal definition file.
func ParseDefinition(path string) (*model.SignalDefinition, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}

var def model.SignalDefinition
if err := json.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parse definition: %w", err)
}

if def.Name == "" {
return nil, fmt.Errorf("definition missing name")
}

if def.Type != "builtin" && def.Type != "external" {
return nil, fmt.Errorf("unknown definition type %q (must be builtin or external)", def.Type)
}

return &def, nil
}

// paramFloat extracts a float64 parameter from a signal definition's Params map.
// Returns defaultVal if the key is absent or not numeric.
func paramFloat(params map[string]interface{}, key string, defaultVal float64) float64 {
if params == nil {
return defaultVal
}
v, ok := params[key]
if !ok {
return defaultVal
}
switch val := v.(type) {
case float64:
return val
case int:
return float64(val)
case int64:
return float64(val)
}
return defaultVal
}

// paramInt extracts an int parameter from a signal definition's Params map.
func paramInt(params map[string]interface{}, key string, defaultVal int) int {
f := paramFloat(params, key, float64(defaultVal))
return int(f)
}
Loading
Loading