Files
nofx/trader/auto_trader_throttle_test.go
tinkle-community 39eac5aca7 config: stop the churn — hold for big moves, wide TP/SL, low leverage
Live decomposition of the losing streak: 23% win rate with avg win +$1.23 /
avg loss -$1.04 on ~0.3-0.5% price moves, where the ~0.14% round-trip fee ate
30-50% of every tiny winner. Death by small-move grinding. The AI was closing
positions on ±0.5% noise after the 60m min-hold, capping winners at ~0.86%.

Redesign to 'few big positions, held for big moves':
- Throttle: min hold 60m->4h, noise-close window 90m->8h, reentry 30m->3h,
  opens/hour 30->3, opens/cycle 6->2. Noise band widened -1%..+2% -> -4%..+6%
  so small moves can no longer trigger a close.
- Exits: stop bypass -2.5% -> -5%, take-profit bypass +5% -> +12% (wide,
  asymmetric — let winners run, cut losers only on a real move).
- Leverage 20x -> 5x: a -5% stop at 20x is instant liquidation; at 5x it is
  -25% of margin, survivable. 2 positions x 2.5x = 5x total (full margin,
  ~20% cushion) instead of 4x5x=20x.
- Prompt now instructs the AI to set wide stops (~-5%) and distant targets
  (~+10-12%), hold multi-hour, and never scalp 0.5% moves.

Live strategy updated (maxPos=2, lev=5, ratio=2.5).
2026-07-21 15:02:23 +09:00

101 lines
3.5 KiB
Go

package trader
import (
"nofx/kernel"
"strings"
"testing"
"time"
)
func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context {
return &kernel.Context{
Positions: []kernel.PositionInfo{
{
Symbol: symbol,
Side: side,
UnrealizedPnLPct: pnlPct,
UpdateTime: time.Now().Add(-heldFor).UnixMilli(),
},
},
}
}
func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -0.3)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if !strings.Contains(reason, "min AI-managed hold") {
t.Fatalf("expected early close to be blocked by min hold, got %q", reason)
}
}
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
at := &AutoTrader{}
// Only a real -5% stop bypasses the min hold now.
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -6.0)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if reason != "" {
t.Fatalf("expected hard stop close to pass, got %q", reason)
}
}
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
at := &AutoTrader{}
// Held past the 4h min hold but still inside the wide -4%..+6% noise band.
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, 0.4)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if !strings.Contains(reason, "noise band") {
t.Fatalf("expected flat close to be blocked inside noise window, got %q", reason)
}
}
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
at := &AutoTrader{}
// Past the 4h min hold, loss beyond the -4% noise floor → close allowed.
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, -4.5)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
if reason != "" {
t.Fatalf("expected confirmed loss after min hold to pass, got %q", reason)
}
}
func TestTradeThrottleAllowsLongShortPairInCycle(t *testing.T) {
at := &AutoTrader{}
ctx := &kernel.Context{}
// One open already queued this cycle (e.g. the long) — the second open
// (the short) must still be allowed so a directional pair can open.
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 1)
if reason != "" {
t.Fatalf("expected the second (short) open in cycle to be allowed, got %q", reason)
}
}
func TestTradeThrottleBlocksOpensOverCycleCap(t *testing.T) {
at := &AutoTrader{}
ctx := &kernel.Context{}
// under the 2-per-cycle cap, a further open is allowed
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1); reason != "" {
t.Fatalf("expected open within the 2-per-cycle cap to be allowed, got %q", reason)
}
// at the cap, the next open is blocked
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 2); !strings.Contains(reason, "2 new position") {
t.Fatalf("expected open beyond the 2-per-cycle cap to be blocked, got %q", reason)
}
}
func TestTradeThrottleBlocksOpeningAgainstExistingPosition(t *testing.T) {
at := &AutoTrader{}
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, 1.0)
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 0)
if !strings.Contains(reason, "already has an open") {
t.Fatalf("expected opposite open to be blocked when position exists, got %q", reason)
}
}