feat: cream terminal redesign, English-only UI, autopilot launch fixes

- Redesign dashboard into a cream-paper + vermilion IBM Plex Mono terminal
  (live L2 order book, cost/liq map, WS K-line, signal matrix, orchestration
  topology, risk radar, execution log, current positions, equity curve)
- Convert all user-facing UI and backend strings/prompts from Chinese to
  English (multi-language retained, default English)
- Add /api/statistics/full endpoint + full-stats frontend wiring
- Fix Autopilot launch: reuse the existing trader instead of creating
  duplicates (eliminates repeat ~35s create cost and stale-trader 404s);
  launch sends 5m scan interval
- Fix unreadable toasts: cream theme with high-contrast text + per-type accent
- Silence background dashboard polls (getTraderConfig) to stop error-toast spam
This commit is contained in:
tinkle-community
2026-06-30 16:03:52 +08:00
parent eba28bcf0e
commit 110bf52908
149 changed files with 6835 additions and 3611 deletions

View File

@@ -418,6 +418,16 @@ func (at *AutoTrader) reloadStrategyConfigIfChanged() error {
}
strategyConfig.ClampLimits()
// Autopilot (vergex_signal/claw402) runs a balanced multi-position book:
// hold several instruments at once with a smaller per-position notional so
// multiple long/short positions fit the margin. Applied after ClampLimits so
// the book size is not capped back down to the conservative default.
if strategyConfig.CoinSource.SourceType == "vergex_signal" {
strategyConfig.RiskControl.MaxPositions = 6
strategyConfig.RiskControl.BTCETHMaxPositionValueRatio = 1.2
strategyConfig.RiskControl.AltcoinMaxPositionValueRatio = 1.2
}
claw402Key := at.config.Claw402WalletKey
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
claw402Key = at.config.CustomAPIKey

View File

@@ -0,0 +1,99 @@
package trader
import (
"strings"
"nofx/kernel"
)
// ensureLongShortCoverage keeps a balanced book each cycle: it fills toward
// roughly half the MaxPositions slots long and half short. The AI still drives
// selection/sizing whenever it acts; this is a deterministic top-up — if the
// AI's decisions plus existing positions fall short of the per-direction target,
// the engine force-opens the strongest unused bullish/bearish candidates to
// reach it (never exceeding MaxPositions).
//
// Forced opens are sized from account equity via applyAutopilotFullSizeOpen and
// run through the same code-enforced risk checks (position-value ratio, minimum
// size, margin) as any other open. Guards:
// - skipped entirely in safe mode (AI unhealthy),
// - scoped to the vergex_signal source (the only one with directional bias),
// - never exceeds MaxPositions,
// - never doubles a base symbol already held or already in the decision set.
func (at *AutoTrader) ensureLongShortCoverage(decisions []kernel.Decision, ctx *kernel.Context, equity float64) []kernel.Decision {
if at == nil || ctx == nil || at.safeMode {
return decisions
}
if at.config.StrategyConfig == nil || at.config.StrategyConfig.CoinSource.SourceType != "vergex_signal" {
return decisions
}
if at.strategyEngine == nil || equity <= 0 {
return decisions
}
maxPos := at.config.StrategyConfig.RiskControl.MaxPositions
if maxPos < 2 {
return decisions
}
// Aim to hold a balanced book: roughly half the slots long, half short.
targetLong := (maxPos + 1) / 2
targetShort := maxPos / 2
held := make(map[string]bool)
longCount, shortCount, posCount := 0, 0, 0
for _, p := range ctx.Positions {
held[universeBaseKey(p.Symbol)] = true
posCount++
if strings.EqualFold(p.Side, "long") {
longCount++
} else if strings.EqualFold(p.Side, "short") {
shortCount++
}
}
for _, d := range decisions {
held[universeBaseKey(d.Symbol)] = true
switch d.Action {
case "open_long":
longCount++
posCount++
case "open_short":
shortCount++
posCount++
}
}
bullish, bearish := at.strategyEngine.DirectionalCandidates()
// fill a direction up to its target, drawing from the strongest unused
// candidates, never exceeding MaxPositions.
fill := func(action string, cands []string, have, target int) {
for _, c := range cands {
if have >= target {
return
}
if maxPos > 0 && posCount >= maxPos {
return
}
b := universeBaseKey(c)
if b == "" || held[b] {
continue
}
d := kernel.Decision{
Action: action,
Symbol: c,
Confidence: 70,
Reasoning: "Forced " + action + " to fill the balanced long/short book (autopilot)",
}
at.applyAutopilotFullSizeOpen(&d, equity)
decisions = append(decisions, d)
held[b] = true
have++
posCount++
at.logInfof("⚖️ Forced %s %s (account-sized %.2f USDT, %dx)", action, c, d.PositionSizeUSD, d.Leverage)
}
}
fill("open_long", bullish, longCount, targetLong)
fill("open_short", bearish, shortCount, targetShort)
return decisions
}

View File

@@ -0,0 +1,57 @@
package trader
import (
"testing"
"nofx/kernel"
"nofx/store"
)
func baseForceTrader() *AutoTrader {
cfg := store.GetDefaultStrategyConfig("en")
cfg.CoinSource.SourceType = "vergex_signal"
cfg.RiskControl.MaxPositions = 5
cfg.RiskControl.AltcoinMaxLeverage = 10
cfg.RiskControl.AltcoinMaxPositionValueRatio = 10
at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}}
at.strategyEngine = kernel.NewStrategyEngine(&cfg) // empty ranking cache
return at
}
func TestEnsureLongShortCoverageSafeModeSkips(t *testing.T) {
at := baseForceTrader()
at.safeMode = true
out := at.ensureLongShortCoverage(nil, &kernel.Context{}, 100)
if len(out) != 0 {
t.Fatalf("safe mode must not force opens, got %d", len(out))
}
}
func TestEnsureLongShortCoverageNonVergexSkips(t *testing.T) {
at := baseForceTrader()
at.config.StrategyConfig.CoinSource.SourceType = "static"
out := at.ensureLongShortCoverage(nil, &kernel.Context{}, 100)
if len(out) != 0 {
t.Fatalf("non-vergex source must not force opens, got %d", len(out))
}
}
func TestEnsureLongShortCoverageBothPresentNoop(t *testing.T) {
at := baseForceTrader()
in := []kernel.Decision{
{Action: "open_long", Symbol: "xyz:AAPL"},
{Action: "open_short", Symbol: "BTC"},
}
out := at.ensureLongShortCoverage(in, &kernel.Context{}, 100)
if len(out) != 2 {
t.Fatalf("both directions already present -> no force, got %d", len(out))
}
}
func TestEnsureLongShortCoverageNoCandidatesNoForce(t *testing.T) {
at := baseForceTrader() // empty ranking cache -> no directional candidates
out := at.ensureLongShortCoverage(nil, &kernel.Context{}, 100)
if len(out) != 0 {
t.Fatalf("no candidates available -> nothing to force, got %d", len(out))
}
}

View File

@@ -222,6 +222,9 @@ func (at *AutoTrader) runCycle() error {
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
sortedDecisions = at.filterDecisionsToStrategyUniverse(sortedDecisions, ctx)
// Per-cycle long/short coverage: if the AI left a direction uncovered, force
// the strongest bullish/bearish candidate (account-sized, risk-enforced).
sortedDecisions = at.ensureLongShortCoverage(sortedDecisions, ctx, ctx.Account.TotalEquity)
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
for i, d := range sortedDecisions {

View File

@@ -12,9 +12,15 @@ import (
const (
autopilotMinHoldDuration = 45 * time.Minute
autopilotNoiseCloseHoldDuration = 90 * time.Minute
autopilotReentryCooldown = 90 * time.Minute
autopilotMaxOpensPerHour = 1
autopilotMaxOpensPerCycle = 1
autopilotReentryCooldown = 30 * time.Minute
// Allow one long + one short per cycle. The real exposure/churn limits are
// MaxPositions (concurrent) + the 45m min-hold + the 90m per-symbol reentry
// cooldown, so the per-hour cap only needs to be high enough not to block the
// directional pair from re-establishing after positions close. A tight value
// here (e.g. 2) starves the strategy: once a couple opens fire, every later
// cycle is blocked and the book drains to flat. Keep it generous.
autopilotMaxOpensPerHour = 30
autopilotMaxOpensPerCycle = 6
earlyCloseStopLossBypassPct = -2.5
earlyCloseTakeProfitBypassPct = 5.0
noiseCloseLossFloorPct = -1.0

View File

@@ -60,13 +60,29 @@ func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
}
}
func TestTradeThrottleBlocksSecondOpenInCycle(t *testing.T) {
func TestTradeThrottleAllowsLongShortPairInCycle(t *testing.T) {
at := &AutoTrader{}
ctx := &kernel.Context{}
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1)
if !strings.Contains(reason, "only 1 new position") {
t.Fatalf("expected second open in cycle to be blocked, got %q", reason)
// 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 6-per-cycle cap, a further open is allowed
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 5); reason != "" {
t.Fatalf("expected open within the 6-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, 6); !strings.Contains(reason, "6 new position") {
t.Fatalf("expected open beyond the 6-per-cycle cap to be blocked, got %q", reason)
}
}

View File

@@ -9,7 +9,7 @@ func TestDefaultBuilderIsHardcodedToApprovedFeeTier(t *testing.T) {
if got := defaultBuilder.Builder; got != "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d" {
t.Fatalf("defaultBuilder.Builder = %s, want hardcoded NOFX builder", got)
}
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (5).
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (5 per 10,000).
// Must match defaultHyperliquidBuilderMaxFee on the API side and the
// frontend HYPERLIQUID_BUILDER_MAX_FEE constant the user signs against.
if got := defaultBuilder.Fee; got != 50 {

View File

@@ -63,7 +63,7 @@ var xyzDexAssets = map[string]bool{
// Users approve this builder during the top-right Hyperliquid connect flow before
// their generated agent wallet is saved for live trading.
//
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (5). Existing
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (5 per 10,000). Existing
// approvals at the prior 0.1% cap remain valid on-chain because 0.05% is
// still within their approved max.
var defaultBuilder = &hyperliquid.BuilderInfo{