Files
nofx/agent/config_visibility_test.go
lky-spec 9ee931ee30 v2
2026-04-25 20:24:46 +08:00

405 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agent
import (
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestToolManageModelConfigCreateRequiresCredential(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"deepseek"}`)
if !strings.Contains(resp, `"error":"api_key is required for create"`) {
t.Fatalf("expected missing api_key error, got: %s", resp)
}
}
func TestToolManageModelConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"qwen","api_key":"sk-test-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
model, err := st.AIModel().Get("default", "default_qwen")
if err != nil {
t.Fatalf("load created model: %v", err)
}
if !model.Enabled {
t.Fatalf("expected agent-created model to default to enabled so it matches manual creation")
}
}
func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-upsert.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_qwen", "qwen1", false, "sk-old-qwen-123456", "", "qwen3-max"); err != nil {
t.Fatalf("seed existing qwen model: %v", err)
}
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"Qwen","api_key":"sk-new-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to reuse existing qwen config instead of failing, got: %s", resp)
}
models, err := st.AIModel().List("default")
if err != nil {
t.Fatalf("list models: %v", err)
}
qwenCount := 0
for _, model := range models {
if model != nil && model.Provider == "qwen" {
qwenCount++
if model.ID != "default_qwen" {
t.Fatalf("expected existing qwen record to be reused, got model id %q", model.ID)
}
if model.Name != "Qwen" {
t.Fatalf("expected reused qwen record to be renamed, got %q", model.Name)
}
if !model.Enabled {
t.Fatalf("expected reused qwen record to be enabled after agent create")
}
}
}
if qwenCount != 1 {
t.Fatalf("expected exactly one qwen record after reuse, got %d", qwenCount)
}
}
func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility-list.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", false, "", "", ""); err != nil {
t.Fatalf("seed incomplete model: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", false, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed configured model: %v", err)
}
resp := a.toolGetModelConfigs("default")
if strings.Contains(resp, `"id":"default_openai"`) {
t.Fatalf("incomplete model should be hidden from tool query: %s", resp)
}
if !strings.Contains(resp, `"id":"default_deepseek"`) {
t.Fatalf("configured model should remain visible: %s", resp)
}
}
func TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-risk-guard.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-risk-guard",
UserID: "default",
Name: "AI500稳重策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-risk-guard","config":{"risk_control":{"btc_eth_max_leverage":100,"altcoin_max_leverage":100}}}`)
if !strings.Contains(resp, `不会按你给的原值直接保存`) {
t.Fatalf("expected out-of-range leverage update to be rejected before save, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.BTCETHMaxLeverage != 5 || parsed.RiskControl.AltcoinMaxLeverage != 5 {
t.Fatalf("expected stored leverage to remain unchanged at safe defaults, got btc_eth=%d alt=%d", parsed.RiskControl.BTCETHMaxLeverage, parsed.RiskControl.AltcoinMaxLeverage)
}
}
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.exchangeSkillOptionSummary("zh")
for _, expected := range []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected option %q in summary, got: %s", expected, summary)
}
}
for _, hidden := range []string{"Alpaca", "Forex", "Metals"} {
if strings.Contains(summary, hidden) {
t.Fatalf("did not expect hidden manual-page option %q in summary: %s", hidden, summary)
}
}
}
func TestDescribeExchangeIncludesTypeSpecificVisibleFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-detail.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
hyperID, err := st.Exchange().Create("default", "hyperliquid", "Dex Pro", true, "hyper-api-key", "", "", true, "0xabc", true, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed hyperliquid exchange: %v", err)
}
detail, ok := a.describeExchange("default", "zh", &EntityReference{ID: hyperID})
if !ok {
t.Fatal("expected describeExchange to resolve hyperliquid config")
}
for _, expected := range []string{"交易所配置“Dex Pro”详情", "交易所hyperliquid", "账户名Dex Pro", "API Keytrue", "Hyperliquid 钱包地址0xabc"} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected hyperliquid detail to contain %q, got: %s", expected, detail)
}
}
lighterID, err := st.Exchange().Create("default", "lighter", "Lighter Main", false, "", "", "", false, "", true, "", "", "", "wallet-1", "", "lighter-secret", 7)
if err != nil {
t.Fatalf("seed lighter exchange: %v", err)
}
detail, ok = a.describeExchange("default", "zh", &EntityReference{ID: lighterID})
if !ok {
t.Fatal("expected describeExchange to resolve lighter config")
}
for _, expected := range []string{"交易所lighter", "Lighter 钱包地址wallet-1", "Lighter API Key 私钥true", "Lighter API Key Index7"} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected lighter detail to contain %q, got: %s", expected, detail)
}
}
}
func TestSkillVisibleFieldSummaryForExchangeUsesReadableNames(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "exchange_management", "update")
for _, expected := range []string{"交易所类型", "账户名", "API Key", "Secret", "Passphrase", "Hyperliquid 钱包地址", "Aster User", "Lighter API Key 私钥", "Lighter API Key Index"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "hyperliquid_wallet_addr") || strings.Contains(summary, "lighter_api_key_private_key") {
t.Fatalf("field summary should use readable labels instead of raw keys: %s", summary)
}
}
func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "strategy_management", "update_config")
for _, expected := range []string{"发布到市场", "配置可见", "交易对", "杠杆", "主周期", "多周期时间框架", "NofxOS API key", "角色定义", "自定义 Prompt"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
}
func TestSkillVisibleFieldSummaryForTraderExcludesManualBalanceEditing(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "trader_management", "update")
for _, expected := range []string{"名称", "交易所", "模型", "策略", "扫描间隔"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "初始资金") || strings.Contains(summary, "初始余额") {
t.Fatalf("trader field summary should not expose manual balance editing: %s", summary)
}
}
func TestToolCreateTraderAutoReadsInitialBalanceFromExchange(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-auto-balance.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-auto-balance",
UserID: "default",
Name: "Auto Balance Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
if exchangeCfg == nil || exchangeCfg.ID != exchangeID {
t.Fatalf("unexpected exchange config passed to balance fetcher: %#v", exchangeCfg)
}
if userID != "default" {
t.Fatalf("unexpected user id %q", userID)
}
return 4321.25, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"奶茶","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-auto-balance","initial_balance":999}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected one trader, got %d", len(traders))
}
if traders[0].InitialBalance != 4321.25 {
t.Fatalf("expected initial balance to be auto-read from exchange, got %.2f", traders[0].InitialBalance)
}
}
func TestDescribeStrategyIncludesManualPageSections(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-detail.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &store.GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 12,
TotalInvestment: 1500,
Leverage: 4,
UpperPrice: 120000,
LowerPrice: 90000,
UseATRBounds: false,
ATRMultiplier: 2,
Distribution: "gaussian",
MaxDrawdownPct: 15,
StopLossPct: 5,
DailyLossLimitPct: 10,
UseMakerOnly: true,
EnableDirectionAdjust: true,
DirectionBiasRatio: 0.7,
}
cfg.CoinSource.SourceType = "mixed"
cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"}
cfg.CoinSource.ExcludedCoins = []string{"DOGEUSDT"}
cfg.Indicators.EnableOIRanking = true
cfg.Indicators.EnableNetFlowRanking = true
cfg.Indicators.EnablePriceRanking = true
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-detail-1",
UserID: "default",
Name: "Grid Alpha",
Description: "grid strategy for regression",
IsPublic: true,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
strategy.ConfigVisible = false
if err := st.Strategy().Update(strategy); err != nil {
t.Fatalf("update strategy visibility: %v", err)
}
detail, ok := a.describeStrategy("default", "zh", &EntityReference{ID: strategy.ID})
if !ok {
t.Fatal("expected describeStrategy to resolve seeded strategy")
}
for _, expected := range []string{
"策略“Grid Alpha”概览",
"发布设置:已发布到市场;配置隐藏",
"网格参数:交易对 BTCUSDT网格 12总投资 1500.00;杠杆 4分布 gaussian",
"网格边界:上沿 120000.0000,下沿 90000.0000",
"标的来源mixed | AI500=3 | static=BTCUSDT,ETHUSDT | excluded=DOGEUSDT",
"NofxOS 数据API Key=true量化数据=trueOI 排行=true净流入排行=true价格排行=true",
} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail)
}
}
}