diff --git a/docs/plans/2026-01-17-grid-market-regime-impl.md b/docs/plans/2026-01-17-grid-market-regime-impl.md new file mode 100644 index 00000000..a28d2b44 --- /dev/null +++ b/docs/plans/2026-01-17-grid-market-regime-impl.md @@ -0,0 +1,1655 @@ +# Grid Market Regime Detection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement multi-period box indicators and 4-level ranging classification for grid trading with automatic parameter adjustment and breakout handling. + +**Architecture:** Add Donchian channel calculation to market package, extend grid models with box/regime fields, implement breakout detection in auto_trader_grid, add risk control panel to frontend. + +**Tech Stack:** Go (backend), React/TypeScript (frontend), GORM (database), 1-hour Kline data + +--- + +## Task 1: Add Donchian Channel Calculation + +**Files:** +- Modify: `market/data.go` +- Test: `market/data_test.go` + +**Step 1: Write the failing test** + +Add to `market/data_test.go`: + +```go +func TestCalculateDonchian(t *testing.T) { + // Create test klines with known high/low values + klines := []Kline{ + {High: 100, Low: 90}, + {High: 105, Low: 88}, + {High: 102, Low: 92}, + {High: 108, Low: 85}, + {High: 103, Low: 91}, + } + + upper, lower := calculateDonchian(klines, 5) + + if upper != 108 { + t.Errorf("Expected upper = 108, got %v", upper) + } + if lower != 85 { + t.Errorf("Expected lower = 85, got %v", lower) + } +} + +func TestCalculateDonchian_PartialPeriod(t *testing.T) { + klines := []Kline{ + {High: 100, Low: 90}, + {High: 105, Low: 88}, + } + + upper, lower := calculateDonchian(klines, 10) + + // Should use all available klines when period > len(klines) + if upper != 105 { + t.Errorf("Expected upper = 105, got %v", upper) + } + if lower != 88 { + t.Errorf("Expected lower = 88, got %v", lower) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./market/... -run TestCalculateDonchian` +Expected: FAIL with "undefined: calculateDonchian" + +**Step 3: Write minimal implementation** + +Add to `market/data.go`: + +```go +// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period +func calculateDonchian(klines []Kline, period int) (upper, lower float64) { + if len(klines) == 0 { + return 0, 0 + } + + // Use all available klines if period > len(klines) + start := len(klines) - period + if start < 0 { + start = 0 + } + + upper = klines[start].High + lower = klines[start].Low + + for i := start + 1; i < len(klines); i++ { + if klines[i].High > upper { + upper = klines[i].High + } + if klines[i].Low < lower { + lower = klines[i].Low + } + } + + return upper, lower +} + +// ExportCalculateDonchian exports calculateDonchian for testing +func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) { + return calculateDonchian(klines, period) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./market/... -run TestCalculateDonchian` +Expected: PASS + +**Step 5: Commit** + +```bash +git add market/data.go market/data_test.go +git commit -m "feat(market): add Donchian channel calculation" +``` + +--- + +## Task 2: Add Box Data Types + +**Files:** +- Modify: `market/types.go` + +**Step 1: Add BoxData struct** + +Add to `market/types.go`: + +```go +// BoxData represents multi-period Donchian channel (box) data +type BoxData struct { + // Short-term box (72 1h candles = 3 days) + ShortUpper float64 `json:"short_upper"` + ShortLower float64 `json:"short_lower"` + + // Mid-term box (240 1h candles = 10 days) + MidUpper float64 `json:"mid_upper"` + MidLower float64 `json:"mid_lower"` + + // Long-term box (500 1h candles = ~21 days) + LongUpper float64 `json:"long_upper"` + LongLower float64 `json:"long_lower"` + + // Current price position relative to boxes + CurrentPrice float64 `json:"current_price"` +} + +// RegimeLevel represents the ranging classification level +type RegimeLevel string + +const ( + RegimeLevelNarrow RegimeLevel = "narrow" // 窄幅震荡 + RegimeLevelStandard RegimeLevel = "standard" // 标准震荡 + RegimeLevelWide RegimeLevel = "wide" // 宽幅震荡 + RegimeLevelVolatile RegimeLevel = "volatile" // 剧烈震荡 + RegimeLevelTrending RegimeLevel = "trending" // 趋势 +) + +// BreakoutLevel represents which box level has been broken +type BreakoutLevel string + +const ( + BreakoutNone BreakoutLevel = "none" + BreakoutShort BreakoutLevel = "short" + BreakoutMid BreakoutLevel = "mid" + BreakoutLong BreakoutLevel = "long" +) +``` + +**Step 2: Commit** + +```bash +git add market/types.go +git commit -m "feat(market): add BoxData and RegimeLevel types" +``` + +--- + +## Task 3: Add GetBoxData Function + +**Files:** +- Modify: `market/data.go` +- Test: `market/data_test.go` + +**Step 1: Write the failing test** + +Add to `market/data_test.go`: + +```go +func TestGetBoxData(t *testing.T) { + // This test requires mocking kline data source + // For now, test the internal calculation logic + klines := make([]Kline, 500) + for i := 0; i < 500; i++ { + // Create synthetic price data + basePrice := 100.0 + klines[i] = Kline{ + High: basePrice + float64(i%10), + Low: basePrice - float64(i%10), + } + } + + box := calculateBoxData(klines, 100.0) + + if box.ShortUpper == 0 || box.ShortLower == 0 { + t.Error("Short box should not be zero") + } + if box.MidUpper == 0 || box.MidLower == 0 { + t.Error("Mid box should not be zero") + } + if box.LongUpper == 0 || box.LongLower == 0 { + t.Error("Long box should not be zero") + } + if box.CurrentPrice != 100.0 { + t.Errorf("Expected CurrentPrice = 100.0, got %v", box.CurrentPrice) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./market/... -run TestGetBoxData` +Expected: FAIL with "undefined: calculateBoxData" + +**Step 3: Write minimal implementation** + +Add to `market/data.go`: + +```go +const ( + ShortBoxPeriod = 72 // 3 days of 1h candles + MidBoxPeriod = 240 // 10 days of 1h candles + LongBoxPeriod = 500 // ~21 days of 1h candles +) + +// calculateBoxData calculates multi-period box data from klines +func calculateBoxData(klines []Kline, currentPrice float64) *BoxData { + box := &BoxData{ + CurrentPrice: currentPrice, + } + + if len(klines) == 0 { + return box + } + + box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod) + box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod) + box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod) + + return box +} + +// GetBoxData fetches 1h klines and calculates box data for a symbol +func GetBoxData(symbol string) (*BoxData, error) { + symbol = Normalize(symbol) + + // Fetch 500 1h klines + var klines []Kline + var err error + + if IsXyzDexAsset(symbol) { + klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod) + } else { + klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod) + } + + if err != nil { + return nil, fmt.Errorf("failed to get 1h klines: %w", err) + } + + if len(klines) == 0 { + return nil, fmt.Errorf("no kline data available") + } + + currentPrice := klines[len(klines)-1].Close + + return calculateBoxData(klines, currentPrice), nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./market/... -run TestGetBoxData` +Expected: PASS + +**Step 5: Commit** + +```bash +git add market/data.go market/data_test.go +git commit -m "feat(market): add GetBoxData for multi-period box calculation" +``` + +--- + +## Task 4: Update GridConfigModel with Box Parameters + +**Files:** +- Modify: `store/grid.go` + +**Step 1: Add new fields to GridConfigModel** + +Add fields after `TrendResumeThreshold` in `store/grid.go`: + +```go + // Box indicator periods (1h candles) + ShortBoxPeriod int `json:"short_box_period" gorm:"default:72"` // 3 days + MidBoxPeriod int `json:"mid_box_period" gorm:"default:240"` // 10 days + LongBoxPeriod int `json:"long_box_period" gorm:"default:500"` // 21 days + + // Effective leverage limits by regime level + NarrowRegimeLeverage int `json:"narrow_regime_leverage" gorm:"default:2"` + StandardRegimeLeverage int `json:"standard_regime_leverage" gorm:"default:4"` + WideRegimeLeverage int `json:"wide_regime_leverage" gorm:"default:3"` + VolatileRegimeLeverage int `json:"volatile_regime_leverage" gorm:"default:2"` + + // Position limits by regime level (percentage of total investment) + NarrowRegimePositionPct float64 `json:"narrow_regime_position_pct" gorm:"default:40"` + StandardRegimePositionPct float64 `json:"standard_regime_position_pct" gorm:"default:70"` + WideRegimePositionPct float64 `json:"wide_regime_position_pct" gorm:"default:60"` + VolatileRegimePositionPct float64 `json:"volatile_regime_position_pct" gorm:"default:40"` +``` + +**Step 2: Commit** + +```bash +git add store/grid.go +git commit -m "feat(store): add box period and regime leverage fields to GridConfigModel" +``` + +--- + +## Task 5: Update GridInstanceModel with Box State + +**Files:** +- Modify: `store/grid.go` + +**Step 1: Add new fields to GridInstanceModel** + +Add fields after `ConsecutiveTrending` in `store/grid.go`: + +```go + // Current regime level (narrow/standard/wide/volatile/trending) + CurrentRegimeLevel string `json:"current_regime_level" gorm:"default:standard"` + + // Box state + ShortBoxUpper float64 `json:"short_box_upper"` + ShortBoxLower float64 `json:"short_box_lower"` + MidBoxUpper float64 `json:"mid_box_upper"` + MidBoxLower float64 `json:"mid_box_lower"` + LongBoxUpper float64 `json:"long_box_upper"` + LongBoxLower float64 `json:"long_box_lower"` + + // Breakout state + BreakoutLevel string `json:"breakout_level" gorm:"default:none"` // none/short/mid/long + BreakoutDirection string `json:"breakout_direction"` // up/down + BreakoutConfirmCount int `json:"breakout_confirm_count" gorm:"default:0"` + BreakoutStartTime time.Time `json:"breakout_start_time"` + + // Position adjustment due to breakout + PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced +``` + +**Step 2: Commit** + +```bash +git add store/grid.go +git commit -m "feat(store): add box state and breakout fields to GridInstanceModel" +``` + +--- + +## Task 6: Add Regime Level Classification + +**Files:** +- Create: `trader/grid_regime.go` +- Test: `trader/grid_regime_test.go` + +**Step 1: Write the failing test** + +Create `trader/grid_regime_test.go`: + +```go +package trader + +import ( + "nofx/market" + "testing" +) + +func TestClassifyRegimeLevel(t *testing.T) { + tests := []struct { + name string + bollingerWidth float64 + atr14Pct float64 + expected market.RegimeLevel + }{ + {"narrow", 1.5, 0.8, market.RegimeLevelNarrow}, + {"standard", 2.5, 1.5, market.RegimeLevelStandard}, + {"wide", 3.5, 2.5, market.RegimeLevelWide}, + {"volatile", 5.0, 4.0, market.RegimeLevelVolatile}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := classifyRegimeLevel(tt.bollingerWidth, tt.atr14Pct) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestClassifyRegimeLevel` +Expected: FAIL with "undefined: classifyRegimeLevel" + +**Step 3: Write minimal implementation** + +Create `trader/grid_regime.go`: + +```go +package trader + +import "nofx/market" + +// classifyRegimeLevel determines the regime level based on market indicators +// bollingerWidth: Bollinger band width as percentage +// atr14Pct: ATR14 as percentage of current price +func classifyRegimeLevel(bollingerWidth, atr14Pct float64) market.RegimeLevel { + // Narrow: Bollinger < 2%, ATR < 1% + if bollingerWidth < 2.0 && atr14Pct < 1.0 { + return market.RegimeLevelNarrow + } + + // Standard: Bollinger 2-3%, ATR 1-2% + if bollingerWidth <= 3.0 && atr14Pct <= 2.0 { + return market.RegimeLevelStandard + } + + // Wide: Bollinger 3-4%, ATR 2-3% + if bollingerWidth <= 4.0 && atr14Pct <= 3.0 { + return market.RegimeLevelWide + } + + // Volatile: Bollinger > 4%, ATR > 3% + return market.RegimeLevelVolatile +} + +// getRegimeLeverageLimit returns the effective leverage limit for a regime level +func getRegimeLeverageLimit(level market.RegimeLevel, config *store.GridStrategyConfig) int { + switch level { + case market.RegimeLevelNarrow: + return config.NarrowRegimeLeverage + case market.RegimeLevelStandard: + return config.StandardRegimeLeverage + case market.RegimeLevelWide: + return config.WideRegimeLeverage + case market.RegimeLevelVolatile: + return config.VolatileRegimeLeverage + default: + return 2 // Conservative default + } +} + +// getRegimePositionLimit returns the position limit percentage for a regime level +func getRegimePositionLimit(level market.RegimeLevel, config *store.GridStrategyConfig) float64 { + switch level { + case market.RegimeLevelNarrow: + return config.NarrowRegimePositionPct + case market.RegimeLevelStandard: + return config.StandardRegimePositionPct + case market.RegimeLevelWide: + return config.WideRegimePositionPct + case market.RegimeLevelVolatile: + return config.VolatileRegimePositionPct + default: + return 40.0 // Conservative default + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestClassifyRegimeLevel` +Expected: PASS + +**Step 5: Commit** + +```bash +git add trader/grid_regime.go trader/grid_regime_test.go +git commit -m "feat(trader): add regime level classification" +``` + +--- + +## Task 7: Add Breakout Detection + +**Files:** +- Modify: `trader/grid_regime.go` +- Test: `trader/grid_regime_test.go` + +**Step 1: Write the failing test** + +Add to `trader/grid_regime_test.go`: + +```go +func TestDetectBoxBreakout(t *testing.T) { + box := &market.BoxData{ + ShortUpper: 100, + ShortLower: 90, + MidUpper: 105, + MidLower: 85, + LongUpper: 110, + LongLower: 80, + CurrentPrice: 95, + } + + // No breakout + level, direction := detectBoxBreakout(box) + if level != market.BreakoutNone { + t.Errorf("Expected no breakout, got %v", level) + } + + // Short breakout up + box.CurrentPrice = 101 + level, direction = detectBoxBreakout(box) + if level != market.BreakoutShort || direction != "up" { + t.Errorf("Expected short breakout up, got %v %v", level, direction) + } + + // Mid breakout down + box.CurrentPrice = 84 + level, direction = detectBoxBreakout(box) + if level != market.BreakoutMid || direction != "down" { + t.Errorf("Expected mid breakout down, got %v %v", level, direction) + } + + // Long breakout up + box.CurrentPrice = 112 + level, direction = detectBoxBreakout(box) + if level != market.BreakoutLong || direction != "up" { + t.Errorf("Expected long breakout up, got %v %v", level, direction) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestDetectBoxBreakout` +Expected: FAIL with "undefined: detectBoxBreakout" + +**Step 3: Write minimal implementation** + +Add to `trader/grid_regime.go`: + +```go +// detectBoxBreakout checks if price has broken out of any box level +// Returns the highest breakout level and direction +func detectBoxBreakout(box *market.BoxData) (market.BreakoutLevel, string) { + price := box.CurrentPrice + + // Check long box first (highest priority) + if price > box.LongUpper { + return market.BreakoutLong, "up" + } + if price < box.LongLower { + return market.BreakoutLong, "down" + } + + // Check mid box + if price > box.MidUpper { + return market.BreakoutMid, "up" + } + if price < box.MidLower { + return market.BreakoutMid, "down" + } + + // Check short box + if price > box.ShortUpper { + return market.BreakoutShort, "up" + } + if price < box.ShortLower { + return market.BreakoutShort, "down" + } + + return market.BreakoutNone, "" +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestDetectBoxBreakout` +Expected: PASS + +**Step 5: Commit** + +```bash +git add trader/grid_regime.go trader/grid_regime_test.go +git commit -m "feat(trader): add box breakout detection" +``` + +--- + +## Task 8: Add Breakout Confirmation Logic + +**Files:** +- Modify: `trader/grid_regime.go` +- Test: `trader/grid_regime_test.go` + +**Step 1: Write the failing test** + +Add to `trader/grid_regime_test.go`: + +```go +func TestBreakoutConfirmation(t *testing.T) { + state := &BreakoutState{ + Level: market.BreakoutShort, + Direction: "up", + ConfirmCount: 0, + } + + // First confirmation + confirmed := confirmBreakout(state, market.BreakoutShort, "up") + if confirmed || state.ConfirmCount != 1 { + t.Errorf("Expected not confirmed, count=1, got confirmed=%v count=%d", confirmed, state.ConfirmCount) + } + + // Second confirmation + confirmed = confirmBreakout(state, market.BreakoutShort, "up") + if confirmed || state.ConfirmCount != 2 { + t.Errorf("Expected not confirmed, count=2, got confirmed=%v count=%d", confirmed, state.ConfirmCount) + } + + // Third confirmation - should confirm + confirmed = confirmBreakout(state, market.BreakoutShort, "up") + if !confirmed || state.ConfirmCount != 3 { + t.Errorf("Expected confirmed, count=3, got confirmed=%v count=%d", confirmed, state.ConfirmCount) + } + + // Reset on price return + state.ConfirmCount = 2 + confirmed = confirmBreakout(state, market.BreakoutNone, "") + if state.ConfirmCount != 0 { + t.Errorf("Expected count reset to 0, got %d", state.ConfirmCount) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestBreakoutConfirmation` +Expected: FAIL with "undefined: BreakoutState" + +**Step 3: Write minimal implementation** + +Add to `trader/grid_regime.go`: + +```go +const BreakoutConfirmRequired = 3 // 3 candles to confirm breakout + +// BreakoutState tracks the current breakout state +type BreakoutState struct { + Level market.BreakoutLevel + Direction string + ConfirmCount int + StartTime time.Time +} + +// confirmBreakout updates breakout state and returns true if breakout is confirmed +func confirmBreakout(state *BreakoutState, currentLevel market.BreakoutLevel, direction string) bool { + // If price returned to box, reset state + if currentLevel == market.BreakoutNone { + state.ConfirmCount = 0 + state.Level = market.BreakoutNone + state.Direction = "" + return false + } + + // If same breakout continues, increment count + if state.Level == currentLevel && state.Direction == direction { + state.ConfirmCount++ + } else { + // New breakout, reset count + state.Level = currentLevel + state.Direction = direction + state.ConfirmCount = 1 + state.StartTime = time.Now() + } + + return state.ConfirmCount >= BreakoutConfirmRequired +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestBreakoutConfirmation` +Expected: PASS + +**Step 5: Commit** + +```bash +git add trader/grid_regime.go trader/grid_regime_test.go +git commit -m "feat(trader): add breakout confirmation logic" +``` + +--- + +## Task 9: Add Breakout Handler + +**Files:** +- Modify: `trader/grid_regime.go` +- Test: `trader/grid_regime_test.go` + +**Step 1: Write the failing test** + +Add to `trader/grid_regime_test.go`: + +```go +func TestGetBreakoutAction(t *testing.T) { + tests := []struct { + level market.BreakoutLevel + expected BreakoutAction + }{ + {market.BreakoutNone, BreakoutActionNone}, + {market.BreakoutShort, BreakoutActionReducePosition}, + {market.BreakoutMid, BreakoutActionPauseGrid}, + {market.BreakoutLong, BreakoutActionCloseAll}, + } + + for _, tt := range tests { + t.Run(string(tt.level), func(t *testing.T) { + action := getBreakoutAction(tt.level) + if action != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, action) + } + }) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestGetBreakoutAction` +Expected: FAIL with "undefined: BreakoutAction" + +**Step 3: Write minimal implementation** + +Add to `trader/grid_regime.go`: + +```go +// BreakoutAction represents the action to take on breakout +type BreakoutAction int + +const ( + BreakoutActionNone BreakoutAction = iota + BreakoutActionReducePosition // Short box breakout: reduce to 50% + BreakoutActionPauseGrid // Mid box breakout: pause grid + cancel orders + BreakoutActionCloseAll // Long box breakout: pause + cancel + close all +) + +// getBreakoutAction returns the appropriate action for a breakout level +func getBreakoutAction(level market.BreakoutLevel) BreakoutAction { + switch level { + case market.BreakoutShort: + return BreakoutActionReducePosition + case market.BreakoutMid: + return BreakoutActionPauseGrid + case market.BreakoutLong: + return BreakoutActionCloseAll + default: + return BreakoutActionNone + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/yida/gopro/open-nofx && go test -v ./trader/... -run TestGetBreakoutAction` +Expected: PASS + +**Step 5: Commit** + +```bash +git add trader/grid_regime.go trader/grid_regime_test.go +git commit -m "feat(trader): add breakout action handler" +``` + +--- + +## Task 10: Integrate Breakout Detection into Grid Cycle + +**Files:** +- Modify: `trader/auto_trader_grid.go` + +**Step 1: Add checkBoxBreakout method** + +Add to `trader/auto_trader_grid.go` after `checkBreakout` function: + +```go +// checkBoxBreakout checks for multi-period box breakouts and takes appropriate action +func (at *AutoTrader) checkBoxBreakout() error { + gridConfig := at.config.StrategyConfig.GridConfig + if gridConfig == nil { + return nil + } + + // Get box data + box, err := market.GetBoxData(gridConfig.Symbol) + if err != nil { + logger.Infof("Failed to get box data: %v", err) + return nil // Non-fatal, continue with other checks + } + + // Update instance with box values + at.gridState.mu.Lock() + // Store box values in grid state for reference + at.gridState.mu.Unlock() + + // Detect breakout + breakoutLevel, direction := detectBoxBreakout(box) + + // Get current breakout state from instance + state := &BreakoutState{ + Level: market.BreakoutLevel(at.gridState.BreakoutLevel), + Direction: at.gridState.BreakoutDirection, + ConfirmCount: at.gridState.BreakoutConfirmCount, + } + + // Check if breakout is confirmed (3 candles) + confirmed := confirmBreakout(state, breakoutLevel, direction) + + // Update grid state + at.gridState.mu.Lock() + at.gridState.BreakoutLevel = string(state.Level) + at.gridState.BreakoutDirection = state.Direction + at.gridState.BreakoutConfirmCount = state.ConfirmCount + at.gridState.mu.Unlock() + + if !confirmed { + return nil + } + + // Take action based on breakout level + action := getBreakoutAction(breakoutLevel) + return at.executeBreakoutAction(action) +} + +// executeBreakoutAction executes the appropriate action for a breakout +func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error { + gridConfig := at.config.StrategyConfig.GridConfig + + switch action { + case BreakoutActionReducePosition: + // Short box breakout: reduce position to 50% + logger.Infof("Short box breakout confirmed, reducing position to 50%%") + at.gridState.mu.Lock() + at.gridState.PositionReductionPct = 50 + at.gridState.mu.Unlock() + return nil + + case BreakoutActionPauseGrid: + // Mid box breakout: pause grid + cancel orders + logger.Infof("Mid box breakout confirmed, pausing grid and canceling orders") + at.gridState.mu.Lock() + at.gridState.IsPaused = true + at.gridState.mu.Unlock() + return at.cancelAllGridOrders() + + case BreakoutActionCloseAll: + // Long box breakout: pause + cancel + close all + logger.Infof("Long box breakout confirmed, closing all positions") + at.gridState.mu.Lock() + at.gridState.IsPaused = true + at.gridState.mu.Unlock() + if err := at.cancelAllGridOrders(); err != nil { + logger.Infof("Failed to cancel orders: %v", err) + } + return at.closeAllPositions() + } + + return nil +} + +// closeAllPositions closes all open positions +func (at *AutoTrader) closeAllPositions() error { + gridConfig := at.config.StrategyConfig.GridConfig + + positions, err := at.trader.GetPositions() + if err != nil { + return fmt.Errorf("failed to get positions: %w", err) + } + + for _, pos := range positions { + symbol, _ := pos["symbol"].(string) + if symbol != gridConfig.Symbol { + continue + } + + size, _ := pos["positionAmt"].(float64) + if size == 0 { + continue + } + + if size > 0 { + _, err = at.trader.CloseLong(symbol, size) + } else { + _, err = at.trader.CloseShort(symbol, -size) + } + if err != nil { + logger.Infof("Failed to close position: %v", err) + } + } + + return nil +} +``` + +**Step 2: Add checkBoxBreakout call to RunGridCycle** + +In `RunGridCycle`, add after existing breakout check: + +```go + // Check multi-period box breakout + if err := at.checkBoxBreakout(); err != nil { + logger.Infof("Box breakout check error: %v", err) + } +``` + +**Step 3: Commit** + +```bash +git add trader/auto_trader_grid.go +git commit -m "feat(trader): integrate box breakout detection into grid cycle" +``` + +--- + +## Task 11: Add False Breakout Recovery + +**Files:** +- Modify: `trader/auto_trader_grid.go` + +**Step 1: Add recovery logic** + +Add to `trader/auto_trader_grid.go`: + +```go +// checkFalseBreakoutRecovery checks if price has returned to box after breakout +func (at *AutoTrader) checkFalseBreakoutRecovery() error { + gridConfig := at.config.StrategyConfig.GridConfig + if gridConfig == nil { + return nil + } + + at.gridState.mu.RLock() + breakoutLevel := at.gridState.BreakoutLevel + isPaused := at.gridState.IsPaused + positionReduction := at.gridState.PositionReductionPct + at.gridState.mu.RUnlock() + + // Only check if we had a breakout + if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused { + return nil + } + + // Get current box data + box, err := market.GetBoxData(gridConfig.Symbol) + if err != nil { + return nil + } + + // Check if price is back inside the long box + if box.CurrentPrice >= box.LongLower && box.CurrentPrice <= box.LongUpper { + logger.Infof("Price returned to box, recovering with 50%% position") + + at.gridState.mu.Lock() + at.gridState.BreakoutLevel = string(market.BreakoutNone) + at.gridState.BreakoutDirection = "" + at.gridState.BreakoutConfirmCount = 0 + at.gridState.PositionReductionPct = 50 // Recover at 50% + at.gridState.IsPaused = false + at.gridState.mu.Unlock() + } + + return nil +} +``` + +**Step 2: Add call in RunGridCycle** + +```go + // Check for false breakout recovery + if err := at.checkFalseBreakoutRecovery(); err != nil { + logger.Infof("False breakout recovery check error: %v", err) + } +``` + +**Step 3: Commit** + +```bash +git add trader/auto_trader_grid.go +git commit -m "feat(trader): add false breakout recovery logic" +``` + +--- + +## Task 12: Update GridState with Box Fields + +**Files:** +- Modify: `trader/auto_trader_grid.go` + +**Step 1: Add box fields to GridState struct** + +Add to `GridState` struct in `trader/auto_trader_grid.go`: + +```go + // Box state + ShortBoxUpper float64 + ShortBoxLower float64 + MidBoxUpper float64 + MidBoxLower float64 + LongBoxUpper float64 + LongBoxLower float64 + + // Breakout state + BreakoutLevel string + BreakoutDirection string + BreakoutConfirmCount int + + // Position reduction (0 = normal, 50 = reduced after false breakout) + PositionReductionPct float64 + + // Current regime level + CurrentRegimeLevel string +``` + +**Step 2: Commit** + +```bash +git add trader/auto_trader_grid.go +git commit -m "feat(trader): add box and regime fields to GridState" +``` + +--- + +## Task 13: Add Frontend Types + +**Files:** +- Modify: `web/src/types.ts` (or equivalent types file) + +**Step 1: Add grid risk info types** + +Add to types file: + +```typescript +export interface GridRiskInfo { + // Leverage info + currentLeverage: number + effectiveLeverage: number + recommendedLeverage: number + + // Position info + currentPosition: number + maxPosition: number + positionPercent: number + + // Liquidation info + liquidationPrice: number + liquidationDistance: number // percentage + + // Market state + regimeLevel: 'narrow' | 'standard' | 'wide' | 'volatile' | 'trending' + + // Box state + shortBoxUpper: number + shortBoxLower: number + midBoxUpper: number + midBoxLower: number + longBoxUpper: number + longBoxLower: number + currentPrice: number + + // Breakout state + breakoutLevel: 'none' | 'short' | 'mid' | 'long' + breakoutDirection: 'up' | 'down' | '' +} +``` + +**Step 2: Commit** + +```bash +git add web/src/types.ts +git commit -m "feat(web): add GridRiskInfo type" +``` + +--- + +## Task 14: Add API Endpoint for Risk Info + +**Files:** +- Modify: `api/server.go` + +**Step 1: Add handler function** + +Add to `api/server.go`: + +```go +// handleGetGridRiskInfo returns current risk information for a grid trader +func (s *Server) handleGetGridRiskInfo(c *gin.Context) { + traderID := c.Param("id") + + trader, err := s.manager.GetTrader(traderID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"}) + return + } + + autoTrader, ok := trader.(*trader.AutoTrader) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "not an auto trader"}) + return + } + + riskInfo := autoTrader.GetGridRiskInfo() + c.JSON(http.StatusOK, riskInfo) +} +``` + +**Step 2: Add route** + +Add route in `setupRoutes`: + +```go + api.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo) +``` + +**Step 3: Commit** + +```bash +git add api/server.go +git commit -m "feat(api): add grid risk info endpoint" +``` + +--- + +## Task 15: Add GetGridRiskInfo Method to AutoTrader + +**Files:** +- Modify: `trader/auto_trader_grid.go` + +**Step 1: Add method** + +Add to `trader/auto_trader_grid.go`: + +```go +// GridRiskInfo contains risk information for frontend display +type GridRiskInfo struct { + CurrentLeverage int `json:"current_leverage"` + EffectiveLeverage float64 `json:"effective_leverage"` + RecommendedLeverage int `json:"recommended_leverage"` + + CurrentPosition float64 `json:"current_position"` + MaxPosition float64 `json:"max_position"` + PositionPercent float64 `json:"position_percent"` + + LiquidationPrice float64 `json:"liquidation_price"` + LiquidationDistance float64 `json:"liquidation_distance"` + + RegimeLevel string `json:"regime_level"` + + ShortBoxUpper float64 `json:"short_box_upper"` + ShortBoxLower float64 `json:"short_box_lower"` + MidBoxUpper float64 `json:"mid_box_upper"` + MidBoxLower float64 `json:"mid_box_lower"` + LongBoxUpper float64 `json:"long_box_upper"` + LongBoxLower float64 `json:"long_box_lower"` + CurrentPrice float64 `json:"current_price"` + + BreakoutLevel string `json:"breakout_level"` + BreakoutDirection string `json:"breakout_direction"` +} + +// GetGridRiskInfo returns current risk information +func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo { + gridConfig := at.config.StrategyConfig.GridConfig + if gridConfig == nil { + return &GridRiskInfo{} + } + + at.gridState.mu.RLock() + defer at.gridState.mu.RUnlock() + + // Get current price + currentPrice, _ := at.trader.GetMarketPrice(gridConfig.Symbol) + + // Calculate effective leverage + totalInvestment := gridConfig.TotalInvestment + leverage := gridConfig.Leverage + + // Get current position value + positions, _ := at.trader.GetPositions() + var currentPositionValue float64 + for _, pos := range positions { + if sym, _ := pos["symbol"].(string); sym == gridConfig.Symbol { + size, _ := pos["positionAmt"].(float64) + entry, _ := pos["entryPrice"].(float64) + currentPositionValue = math.Abs(size * entry) + break + } + } + + effectiveLeverage := currentPositionValue / totalInvestment + + // Calculate max position based on regime + regimeLevel := market.RegimeLevel(at.gridState.CurrentRegimeLevel) + maxPositionPct := getRegimePositionLimit(regimeLevel, gridConfig) + maxPosition := totalInvestment * maxPositionPct / 100 * float64(leverage) + recommendedLeverage := getRegimeLeverageLimit(regimeLevel, gridConfig) + + // Calculate liquidation distance + liquidationDistance := 100.0 / float64(leverage) * 0.9 // ~90% of theoretical max + + var liquidationPrice float64 + if currentPositionValue > 0 { + liquidationPrice = currentPrice * (1 - liquidationDistance/100) + } + + return &GridRiskInfo{ + CurrentLeverage: leverage, + EffectiveLeverage: effectiveLeverage, + RecommendedLeverage: recommendedLeverage, + + CurrentPosition: currentPositionValue, + MaxPosition: maxPosition, + PositionPercent: currentPositionValue / maxPosition * 100, + + LiquidationPrice: liquidationPrice, + LiquidationDistance: liquidationDistance, + + RegimeLevel: at.gridState.CurrentRegimeLevel, + + ShortBoxUpper: at.gridState.ShortBoxUpper, + ShortBoxLower: at.gridState.ShortBoxLower, + MidBoxUpper: at.gridState.MidBoxUpper, + MidBoxLower: at.gridState.MidBoxLower, + LongBoxUpper: at.gridState.LongBoxUpper, + LongBoxLower: at.gridState.LongBoxLower, + CurrentPrice: currentPrice, + + BreakoutLevel: at.gridState.BreakoutLevel, + BreakoutDirection: at.gridState.BreakoutDirection, + } +} +``` + +**Step 2: Commit** + +```bash +git add trader/auto_trader_grid.go +git commit -m "feat(trader): add GetGridRiskInfo method" +``` + +--- + +## Task 16: Create GridRiskPanel Component + +**Files:** +- Create: `web/src/components/strategy/GridRiskPanel.tsx` + +**Step 1: Create component** + +Create `web/src/components/strategy/GridRiskPanel.tsx`: + +```tsx +import { useState, useEffect } from 'react' +import { AlertTriangle, TrendingUp, Shield, Box } from 'lucide-react' + +interface GridRiskInfo { + currentLeverage: number + effectiveLeverage: number + recommendedLeverage: number + currentPosition: number + maxPosition: number + positionPercent: number + liquidationPrice: number + liquidationDistance: number + regimeLevel: string + shortBoxUpper: number + shortBoxLower: number + midBoxUpper: number + midBoxLower: number + longBoxUpper: number + longBoxLower: number + currentPrice: number + breakoutLevel: string + breakoutDirection: string +} + +interface GridRiskPanelProps { + traderId: string + language: string +} + +export function GridRiskPanel({ traderId, language }: GridRiskPanelProps) { + const [riskInfo, setRiskInfo] = useState(null) + const [loading, setLoading] = useState(true) + + const t = (key: string) => { + const translations: Record> = { + leverageInfo: { zh: '杠杆信息', en: 'Leverage Info' }, + currentLeverage: { zh: '当前杠杆', en: 'Current Leverage' }, + effectiveLeverage: { zh: '有效杠杆', en: 'Effective Leverage' }, + recommendedLeverage: { zh: '推荐杠杆', en: 'Recommended Leverage' }, + positionInfo: { zh: '仓位信息', en: 'Position Info' }, + currentPosition: { zh: '当前仓位', en: 'Current Position' }, + maxPosition: { zh: '最大仓位', en: 'Max Position' }, + liquidationInfo: { zh: '爆仓信息', en: 'Liquidation Info' }, + liquidationPrice: { zh: '爆仓价格', en: 'Liquidation Price' }, + liquidationDistance: { zh: '爆仓距离', en: 'Distance' }, + marketState: { zh: '市场状态', en: 'Market State' }, + regimeLevel: { zh: '震荡级别', en: 'Regime Level' }, + boxState: { zh: '箱体状态', en: 'Box State' }, + shortBox: { zh: '短期箱体', en: 'Short Box' }, + midBox: { zh: '中期箱体', en: 'Mid Box' }, + longBox: { zh: '长期箱体', en: 'Long Box' }, + narrow: { zh: '窄幅震荡', en: 'Narrow' }, + standard: { zh: '标准震荡', en: 'Standard' }, + wide: { zh: '宽幅震荡', en: 'Wide' }, + volatile: { zh: '剧烈震荡', en: 'Volatile' }, + trending: { zh: '趋势', en: 'Trending' }, + breakout: { zh: '突破', en: 'Breakout' }, + none: { zh: '无', en: 'None' }, + } + return translations[key]?.[language] || key + } + + useEffect(() => { + const fetchRiskInfo = async () => { + try { + const res = await fetch(`/api/traders/${traderId}/grid-risk`) + if (res.ok) { + const data = await res.json() + setRiskInfo(data) + } + } catch (err) { + console.error('Failed to fetch risk info:', err) + } finally { + setLoading(false) + } + } + + fetchRiskInfo() + const interval = setInterval(fetchRiskInfo, 10000) // Update every 10s + return () => clearInterval(interval) + }, [traderId]) + + if (loading || !riskInfo) { + return
+ } + + const getRegimeColor = (level: string) => { + switch (level) { + case 'narrow': return 'text-green-400' + case 'standard': return 'text-blue-400' + case 'wide': return 'text-yellow-400' + case 'volatile': return 'text-orange-400' + case 'trending': return 'text-red-400' + default: return 'text-gray-400' + } + } + + return ( +
+ {/* Leverage Info */} +
+

+ + {t('leverageInfo')} +

+
+
+
{t('currentLeverage')}
+
{riskInfo.currentLeverage}x
+
+
+
{t('effectiveLeverage')}
+
{riskInfo.effectiveLeverage.toFixed(2)}x
+
+
+
{t('recommendedLeverage')}
+
{riskInfo.recommendedLeverage}x
+
+
+
+ + {/* Position Info */} +
+

+ + {t('positionInfo')} +

+
+
+
{t('currentPosition')}
+
${riskInfo.currentPosition.toFixed(2)}
+
+
+
{t('maxPosition')}
+
${riskInfo.maxPosition.toFixed(2)}
+
+
+
+
+
+
+ + {/* Liquidation Info */} +
+

+ + {t('liquidationInfo')} +

+
+
+
{t('liquidationPrice')}
+
${riskInfo.liquidationPrice.toFixed(2)}
+
+
+
{t('liquidationDistance')}
+
{riskInfo.liquidationDistance.toFixed(1)}%
+
+
+
+ + {/* Market State */} +
+

+ + {t('marketState')} +

+
+
+
{t('regimeLevel')}
+
+ {t(riskInfo.regimeLevel)} +
+
+ {riskInfo.breakoutLevel !== 'none' && ( +
+ {t('breakout')}: {riskInfo.breakoutLevel} ({riskInfo.breakoutDirection}) +
+ )} +
+
+ + {/* Box State */} +
+

{t('boxState')}

+
+
+ {t('shortBox')} + {riskInfo.shortBoxLower.toFixed(2)} - {riskInfo.shortBoxUpper.toFixed(2)} +
+
+ {t('midBox')} + {riskInfo.midBoxLower.toFixed(2)} - {riskInfo.midBoxUpper.toFixed(2)} +
+
+ {t('longBox')} + {riskInfo.longBoxLower.toFixed(2)} - {riskInfo.longBoxUpper.toFixed(2)} +
+
+ Current Price + ${riskInfo.currentPrice.toFixed(2)} +
+
+
+
+ ) +} +``` + +**Step 2: Commit** + +```bash +git add web/src/components/strategy/GridRiskPanel.tsx +git commit -m "feat(web): add GridRiskPanel component" +``` + +--- + +## Task 17: Update AI Prompt with Box Indicators + +**Files:** +- Modify: `kernel/grid_engine.go` + +**Step 1: Update BuildGridUserPrompt to include box data** + +Add box data section to the prompt in `kernel/grid_engine.go`: + +```go +// In BuildGridUserPrompt function, add after market data section: + + // Box Indicator Section + if gridCtx.BoxData != nil { + sb.WriteString("\n## Box Indicators (Donchian Channels)\n\n") + sb.WriteString("| Box Level | Upper | Lower | Width |\n") + sb.WriteString("|-----------|-------|-------|-------|\n") + + shortWidth := (gridCtx.BoxData.ShortUpper - gridCtx.BoxData.ShortLower) / gridCtx.BoxData.CurrentPrice * 100 + midWidth := (gridCtx.BoxData.MidUpper - gridCtx.BoxData.MidLower) / gridCtx.BoxData.CurrentPrice * 100 + longWidth := (gridCtx.BoxData.LongUpper - gridCtx.BoxData.LongLower) / gridCtx.BoxData.CurrentPrice * 100 + + sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n", + gridCtx.BoxData.ShortUpper, gridCtx.BoxData.ShortLower, shortWidth)) + sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n", + gridCtx.BoxData.MidUpper, gridCtx.BoxData.MidLower, midWidth)) + sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n", + gridCtx.BoxData.LongUpper, gridCtx.BoxData.LongLower, longWidth)) + + // Price position + sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", gridCtx.BoxData.CurrentPrice)) + + // Check position relative to boxes + price := gridCtx.BoxData.CurrentPrice + if price > gridCtx.BoxData.LongUpper || price < gridCtx.BoxData.LongLower { + sb.WriteString("⚠️ BREAKOUT: Price outside long-term box!\n") + } else if price > gridCtx.BoxData.MidUpper || price < gridCtx.BoxData.MidLower { + sb.WriteString("⚠️ WARNING: Price approaching long-term box boundary\n") + } + } +``` + +**Step 2: Update GridContext struct** + +Add BoxData field to GridContext: + +```go +type GridContext struct { + // ... existing fields ... + + // Box data + BoxData *market.BoxData +} +``` + +**Step 3: Commit** + +```bash +git add kernel/grid_engine.go +git commit -m "feat(kernel): add box indicators to AI prompt" +``` + +--- + +## Task 18: Database Migration + +**Files:** +- Modify: `store/grid.go` + +**Step 1: Update InitGridSchema to migrate new fields** + +The GORM AutoMigrate will handle adding new columns. Verify by running: + +```bash +cd /Users/yida/gopro/open-nofx && go run . migrate +``` + +**Step 2: Commit** + +```bash +git add store/grid.go +git commit -m "chore(store): ensure new grid fields are migrated" +``` + +--- + +## Task 19: Run All Tests + +**Step 1: Run backend tests** + +```bash +cd /Users/yida/gopro/open-nofx && go test -v ./... +``` + +**Step 2: Run frontend tests (if available)** + +```bash +cd /Users/yida/gopro/open-nofx/web && npm test +``` + +**Step 3: Fix any failing tests and commit** + +```bash +git add . +git commit -m "test: fix tests for grid regime implementation" +``` + +--- + +## Task 20: Final Integration Test + +**Step 1: Start the server** + +```bash +cd /Users/yida/gopro/open-nofx && go run . +``` + +**Step 2: Verify API endpoint** + +```bash +curl http://localhost:8080/api/traders//grid-risk +``` + +**Step 3: Verify frontend displays risk panel** + +Open browser and check grid trading page shows risk panel. + +**Step 4: Final commit** + +```bash +git add . +git commit -m "feat: complete grid market regime detection implementation" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Donchian calculation | market/data.go | +| 2 | Box data types | market/types.go | +| 3 | GetBoxData function | market/data.go | +| 4 | GridConfigModel fields | store/grid.go | +| 5 | GridInstanceModel fields | store/grid.go | +| 6 | Regime classification | trader/grid_regime.go | +| 7 | Breakout detection | trader/grid_regime.go | +| 8 | Breakout confirmation | trader/grid_regime.go | +| 9 | Breakout handler | trader/grid_regime.go | +| 10 | Grid cycle integration | trader/auto_trader_grid.go | +| 11 | False breakout recovery | trader/auto_trader_grid.go | +| 12 | GridState fields | trader/auto_trader_grid.go | +| 13 | Frontend types | web/src/types.ts | +| 14 | API endpoint | api/server.go | +| 15 | GetGridRiskInfo method | trader/auto_trader_grid.go | +| 16 | GridRiskPanel component | web/src/components/ | +| 17 | AI prompt update | kernel/grid_engine.go | +| 18 | Database migration | store/grid.go | +| 19 | Run all tests | - | +| 20 | Integration test | - |