mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-22 03:37:36 +08:00
Compare commits
8 Commits
d84e22ab82
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39eac5aca7 | ||
|
|
0f3e71560c | ||
|
|
eabd279d10 | ||
|
|
09b7ac9e92 | ||
|
|
dc68884559 | ||
|
|
21407030ea | ||
|
|
7a66d048f3 | ||
|
|
8c8cd9b61f |
@@ -263,10 +263,13 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
c.CoinSource.VergexMarketType = "all"
|
||||
c.CoinSource.VergexChain = "hyperliquid"
|
||||
c.RiskControl.MaxPositions = 2
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 10
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
|
||||
c.RiskControl.BTCETHMaxLeverage = 5
|
||||
c.RiskControl.AltcoinMaxLeverage = 5
|
||||
// Few, concentrated positions held for big moves. 5x leverage so a
|
||||
// wide (-5%) stop is survivable rather than an instant liquidation;
|
||||
// 2 positions × 2.5x = 5x total notional (full margin, ~20% cushion).
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 2.5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.5
|
||||
c.RiskControl.MaxMarginUsage = 1.0
|
||||
c.RiskControl.MinConfidence = 78
|
||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
||||
|
||||
@@ -54,16 +54,16 @@ func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
|
||||
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
|
||||
}
|
||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 {
|
||||
t.Fatalf("default strategy should be Claw402/Vergex native with at most two positions, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions != 2 {
|
||||
t.Fatalf("default strategy should be Claw402/Vergex native with a 2-position concentrated book, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
|
||||
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 5 || trendCfg.RiskControl.AltcoinMaxLeverage != 5 {
|
||||
t.Fatalf("default strategy should use 5x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 2.5 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 2.5 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should use full-size 10x notional for Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
t.Fatalf("default strategy should size Claw402 opens at 2.5x equity notional (2 positions = 5x total at 5x), got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,9 +215,9 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 60 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs roughly 0.1%% of notional (about 1%% of margin at 10x). Only take setups whose expected move to target is at least 3x that cost; fewer, higher-conviction, longer-hold trades beat frequent scalps.\n")
|
||||
sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
@@ -232,9 +232,9 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 60 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs roughly 0.1%% of notional (about 1%% of margin at 10x). Only take setups whose expected move to target is at least 3x that cost; fewer, higher-conviction, longer-hold trades beat frequent scalps.\n")
|
||||
sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
}
|
||||
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) {
|
||||
if !strings.Contains(prompt, "Direction must be data-driven") {
|
||||
t.Fatalf("prompt should explain that direction is data-driven, not long-only:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "every open position must use exactly 10x") {
|
||||
t.Fatalf("prompt should force 10x leverage for Claw402 opens:\n%s", prompt)
|
||||
if !strings.Contains(prompt, "every open position must use exactly 5x") {
|
||||
t.Fatalf("prompt should force 5x leverage for Claw402 opens:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "use the full max notional per position") {
|
||||
t.Fatalf("prompt should force full-size Claw402 opens:\n%s", prompt)
|
||||
|
||||
@@ -658,15 +658,10 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err)
|
||||
}
|
||||
strategyConfig.ClampLimits()
|
||||
// Autopilot (vergex_signal/claw402) runs a balanced multi-position book:
|
||||
// hold several instruments with a smaller per-position notional so multiple
|
||||
// long/short positions fit the margin. Applied after ClampLimits so it is
|
||||
// not capped back to the conservative single-position default.
|
||||
if strategyConfig.CoinSource.SourceType == "vergex_signal" {
|
||||
strategyConfig.RiskControl.MaxPositions = 6
|
||||
strategyConfig.RiskControl.BTCETHMaxPositionValueRatio = 1.2
|
||||
strategyConfig.RiskControl.AltcoinMaxPositionValueRatio = 1.2
|
||||
}
|
||||
// Sizing comes from the strategy's own RiskControl (a hardcoded
|
||||
// 6-position × equity×1.2 override used to live here, silently ignoring
|
||||
// the user's configuration). ClampLimits bounds the values and the
|
||||
// margin auto-reduce at order time keeps the book solvent.
|
||||
logger.Infof("✓ Trader %s loaded strategy config: %s (maxPos=%d, posRatio=%.1f)", traderCfg.Name, strategy.Name, strategyConfig.RiskControl.MaxPositions, strategyConfig.RiskControl.AltcoinMaxPositionValueRatio)
|
||||
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
|
||||
} else {
|
||||
|
||||
@@ -347,6 +347,27 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOpenPositionsByExchange returns every OPEN row on an exchange account,
|
||||
// across all trader IDs. An exchange account is shared by every "NOFX
|
||||
// Autopilot" relaunch (each relaunch mints a fresh trader_id), so reconciling
|
||||
// must be scoped to the exchange — not the current trader_id — or rows left by
|
||||
// prior incarnations become permanent orphans that never close.
|
||||
func (s *PositionStore) GetOpenPositionsByExchange(exchangeID string) ([]*TraderPosition, error) {
|
||||
var positions []*TraderPosition
|
||||
err := s.db.Where("exchange_id = ? AND status = ?", exchangeID, "OPEN").
|
||||
Order("entry_time DESC").
|
||||
Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query open positions by exchange: %w", err)
|
||||
}
|
||||
for _, pos := range positions {
|
||||
if pos.EntryQuantity == 0 {
|
||||
pos.EntryQuantity = pos.Quantity
|
||||
}
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||
var pos TraderPosition
|
||||
|
||||
111
store/position_reconcile.go
Normal file
111
store/position_reconcile.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
const reconcileQuantityTolerance = 0.0001
|
||||
|
||||
// LivePositionKey builds the map key used by ReconcileOpenPositionsWithLive.
|
||||
func LivePositionKey(symbol, side string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(symbol)) + "|" + strings.ToUpper(strings.TrimSpace(side))
|
||||
}
|
||||
|
||||
// ReconcileOpenPositionsWithLive force-closes local OPEN position rows that the
|
||||
// exchange no longer holds, and trims rows whose quantity exceeds what is live.
|
||||
//
|
||||
// Why this exists: missed or unmatched fills (position flips, liquidations,
|
||||
// sync gaps) leave "zombie" OPEN rows behind. Every later close on the same
|
||||
// symbol+side then lands as a partial close against the zombie, so the row
|
||||
// never reaches CLOSED and its realized PnL never enters the closed-trade
|
||||
// statistics — the dashboard, the Edge Profile and the AI's own track-record
|
||||
// context all silently under-report. Reconciling against the exchange's live
|
||||
// book is the self-healing fix: local OPEN rows must always be a subset of
|
||||
// what the exchange actually holds.
|
||||
//
|
||||
// liveQty maps LivePositionKey(symbol, side) → live quantity on the exchange.
|
||||
// Rows are matched newest-first so the freshest row survives as the live
|
||||
// position's bookkeeping and older duplicates get closed.
|
||||
//
|
||||
// Scope is by exchange account (all trader IDs), so rows left by prior
|
||||
// autopilot incarnations on the same exchange are reconciled too.
|
||||
func (s *PositionStore) ReconcileOpenPositionsWithLive(exchangeID string, liveQty map[string]float64) (int, error) {
|
||||
openRows, err := s.GetOpenPositionsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to list open positions: %w", err)
|
||||
}
|
||||
if len(openRows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Copy so we can consume quantities without mutating the caller's map.
|
||||
remaining := make(map[string]float64, len(liveQty))
|
||||
for k, v := range liveQty {
|
||||
remaining[strings.ToUpper(k)] = v
|
||||
}
|
||||
|
||||
// Newest first: the most recent row keeps representing the live position.
|
||||
sort.Slice(openRows, func(i, j int) bool {
|
||||
return openRows[i].EntryTime > openRows[j].EntryTime
|
||||
})
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
closed := 0
|
||||
|
||||
for _, row := range openRows {
|
||||
key := LivePositionKey(row.Symbol, row.Side)
|
||||
live := remaining[key]
|
||||
|
||||
if live > reconcileQuantityTolerance {
|
||||
// The exchange still holds (part of) this key — this row survives.
|
||||
if row.Quantity > live+reconcileQuantityTolerance {
|
||||
// Trim the row down to what is actually live so the next real
|
||||
// close matches sizes and can fully close it. No PnL is
|
||||
// fabricated: the trimmed residue is stale bookkeeping, not a
|
||||
// real fill.
|
||||
trim := row.Quantity - live
|
||||
exitPrice := row.ExitPrice
|
||||
if exitPrice <= 0 {
|
||||
exitPrice = row.EntryPrice
|
||||
}
|
||||
if err := s.ReducePositionQuantity(row.ID, trim, exitPrice, 0, 0); err != nil {
|
||||
logger.Infof(" ⚠️ Reconcile: failed to trim position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err)
|
||||
} else {
|
||||
logger.Infof(" 🧹 Reconcile: trimmed %s %s row %d by %.6f to match live %.6f", row.Symbol, row.Side, row.ID, trim, live)
|
||||
}
|
||||
remaining[key] = 0
|
||||
} else {
|
||||
remaining[key] = live - row.Quantity
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Nothing (left) on the exchange for this key — the row is a zombie.
|
||||
// Close it with whatever it accumulated; exit info falls back to the
|
||||
// last known bookkeeping on the row.
|
||||
exitPrice := row.ExitPrice
|
||||
if exitPrice <= 0 {
|
||||
exitPrice = row.EntryPrice
|
||||
}
|
||||
exitTime := row.UpdatedAt
|
||||
if exitTime <= 0 {
|
||||
exitTime = nowMs
|
||||
}
|
||||
if err := s.ClosePositionFully(row.ID, exitPrice, row.ExitOrderID, exitTime, row.RealizedPnL, row.Fee, "reconcile"); err != nil {
|
||||
logger.Infof(" ⚠️ Reconcile: failed to close zombie position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err)
|
||||
continue
|
||||
}
|
||||
closed++
|
||||
logger.Infof(" 🧹 Reconcile: closed zombie %s %s row %d (qty %.6f, accumulated PnL %.2f) — not held on exchange", row.Symbol, row.Side, row.ID, row.Quantity, row.RealizedPnL)
|
||||
}
|
||||
|
||||
if closed > 0 {
|
||||
logger.Infof("✅ Position reconcile: closed %d zombie row(s) on exchange %s", closed, exchangeID)
|
||||
}
|
||||
return closed, nil
|
||||
}
|
||||
113
store/position_reconcile_test.go
Normal file
113
store/position_reconcile_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newReconcileTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func openRow(t *testing.T, st *Store, traderID, exchangeID, symbol, side string, qty, pnl float64, entryMs int64) int64 {
|
||||
t.Helper()
|
||||
pos := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: "hyperliquid",
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: qty,
|
||||
EntryQuantity: qty,
|
||||
EntryPrice: 100,
|
||||
EntryTime: entryMs,
|
||||
RealizedPnL: pnl,
|
||||
Status: "OPEN",
|
||||
Source: "sync",
|
||||
CreatedAt: entryMs,
|
||||
UpdatedAt: entryMs,
|
||||
}
|
||||
if err := st.Position().CreateOpenPosition(pos); err != nil {
|
||||
t.Fatalf("create open position: %v", err)
|
||||
}
|
||||
return pos.ID
|
||||
}
|
||||
|
||||
func TestReconcileClosesZombiesKeepsLiveAndTrims(t *testing.T) {
|
||||
st := newReconcileTestStore(t)
|
||||
const exch = "ex-hl"
|
||||
base := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||
|
||||
// Zombie under the CURRENT trader id: exchange holds nothing for DRAM.
|
||||
zombieID := openRow(t, st, "trader-now", exch, "xyz:DRAM", "LONG", 6.8, -20.34, base)
|
||||
|
||||
// Zombie left by a PRIOR autopilot incarnation on the SAME exchange —
|
||||
// this is the case a per-trader-id reconcile would miss.
|
||||
legacyID := openRow(t, st, "trader-old", exch, "SOLUSDT", "SHORT", 6.94, -3.5, base+500)
|
||||
|
||||
// Duplicates for SP500 (different incarnations): newest survives trimmed,
|
||||
// older closes.
|
||||
oldSP := openRow(t, st, "trader-old", exch, "xyz:SP500", "LONG", 0.07, -1.5, base+1000)
|
||||
newSP := openRow(t, st, "trader-now", exch, "xyz:SP500", "LONG", 0.124, 2.5, base+2000)
|
||||
|
||||
// Healthy row exactly matching live — untouched.
|
||||
healthy := openRow(t, st, "trader-now", exch, "BTCUSDT", "LONG", 0.01, 0, base+3000)
|
||||
|
||||
// Row on a DIFFERENT exchange account — must be out of scope.
|
||||
otherExch := openRow(t, st, "trader-now", "ex-other", "ETHUSDT", "LONG", 2.0, 0, base+4000)
|
||||
|
||||
live := map[string]float64{
|
||||
LivePositionKey("xyz:SP500", "long"): 0.057,
|
||||
LivePositionKey("BTCUSDT", "long"): 0.01,
|
||||
}
|
||||
|
||||
closed, err := st.Position().ReconcileOpenPositionsWithLive(exch, live)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile failed: %v", err)
|
||||
}
|
||||
if closed != 3 {
|
||||
t.Fatalf("expected 3 zombies closed (DRAM + legacy SOL + old SP500), got %d", closed)
|
||||
}
|
||||
|
||||
get := func(id int64) *TraderPosition {
|
||||
t.Helper()
|
||||
var pos TraderPosition
|
||||
if err := st.Position().db.First(&pos, id).Error; err != nil {
|
||||
t.Fatalf("load position %d: %v", id, err)
|
||||
}
|
||||
return &pos
|
||||
}
|
||||
|
||||
if dram := get(zombieID); dram.Status != "CLOSED" || dram.RealizedPnL != -20.34 || dram.CloseReason != "reconcile" {
|
||||
t.Fatalf("DRAM zombie should close via reconcile keeping PnL, got %+v", dram)
|
||||
}
|
||||
if sol := get(legacyID); sol.Status != "CLOSED" {
|
||||
t.Fatalf("legacy-incarnation SOL zombie on same exchange should close, got %+v", sol)
|
||||
}
|
||||
if sp := get(oldSP); sp.Status != "CLOSED" {
|
||||
t.Fatalf("older duplicate SP500 row should close, got %+v", sp)
|
||||
}
|
||||
if sp := get(newSP); sp.Status != "OPEN" || sp.Quantity > 0.0571 || sp.Quantity < 0.0569 {
|
||||
t.Fatalf("newest SP500 row should stay open trimmed to live 0.057, got status=%s qty=%v", sp.Status, sp.Quantity)
|
||||
}
|
||||
if btc := get(healthy); btc.Status != "OPEN" || btc.Quantity != 0.01 {
|
||||
t.Fatalf("healthy row must be untouched, got %+v", btc)
|
||||
}
|
||||
if eth := get(otherExch); eth.Status != "OPEN" {
|
||||
t.Fatalf("row on a different exchange must be out of scope, got %+v", eth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNoOpenRowsIsNoop(t *testing.T) {
|
||||
st := newReconcileTestStore(t)
|
||||
closed, err := st.Position().ReconcileOpenPositionsWithLive("ex-empty", map[string]float64{})
|
||||
if err != nil || closed != 0 {
|
||||
t.Fatalf("expected clean noop, got closed=%d err=%v", closed, err)
|
||||
}
|
||||
}
|
||||
@@ -1014,11 +1014,11 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
PriceRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 6, // Hold up to 6 instruments (≈3 long + 3 short) simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 10, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 10, // TradeFi exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 1.2, // Per-position notional = equity × 1.2 so several positions fit the margin
|
||||
AltcoinMaxPositionValueRatio: 1.2, // Per-position notional = equity × 1.2 so several positions fit the margin
|
||||
MaxPositions: 2, // Few, concentrated positions held for big moves (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // Low leverage so a big (-5%) stop is survivable, not an instant liquidation
|
||||
AltcoinMaxLeverage: 5, // Low leverage so a big (-5%) stop is survivable, not an instant liquidation
|
||||
BTCETHMaxPositionValueRatio: 2.5, // Per-position notional = equity × 2.5; 2 positions = 5x total (full margin at 5x, ~20% liquidation cushion)
|
||||
AltcoinMaxPositionValueRatio: 2.5, // Per-position notional = equity × 2.5; 2 positions = 5x total (full margin at 5x, ~20% liquidation cushion)
|
||||
MaxMarginUsage: 1.0, // Claw402 Autopilot intentionally uses full margin when opening
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
|
||||
@@ -422,15 +422,11 @@ 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
|
||||
}
|
||||
// NOTE: this used to hardcode the Autopilot book shape (6 positions ×
|
||||
// equity×1.2 notional), silently overriding whatever the user configured in
|
||||
// their strategy. Sizing now comes from the strategy's own RiskControl —
|
||||
// ClampLimits above bounds it (ratio 0.5–10, leverage caps), and the
|
||||
// margin auto-reduce at order time keeps the book solvent.
|
||||
|
||||
claw402Key := at.config.Claw402WalletKey
|
||||
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
)
|
||||
|
||||
// forcedCoverageMinScore is the minimum absolute board z-score a candidate
|
||||
// needs before the engine will force-open it for book balance. Live trade
|
||||
// history showed forced entries on near-neutral signals (|z| < 0.3) were a
|
||||
// systematic money loser — especially shorts — while trades on strong signals
|
||||
// carried the edge. Below this bar the book is simply left unbalanced.
|
||||
const forcedCoverageMinScore = 0.75
|
||||
// needs before the engine will force-open it for book balance. Near-neutral
|
||||
// signals (|z| < ~0.3) proved a systematic loser, but a 0.75 floor was too
|
||||
// strict: in a long-leaning tape every bearish candidate scored below it, so
|
||||
// no short ever opened and the book became a one-directional long bet that
|
||||
// drew down hard. 0.4 keeps genuine directional signals while still filtering
|
||||
// pure noise, so the book can actually hedge.
|
||||
const forcedCoverageMinScore = 0.4
|
||||
|
||||
// ensureLongShortCoverage tops the book up toward roughly half the
|
||||
// MaxPositions slots long and half short — but only with candidates whose
|
||||
|
||||
@@ -10,23 +10,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Live history: trades held under an hour were net-negative after fees
|
||||
// (the 15-60m bucket bled), while the edge concentrated in 1h+ holds.
|
||||
autopilotMinHoldDuration = 60 * time.Minute
|
||||
autopilotNoiseCloseHoldDuration = 90 * time.Minute
|
||||
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
|
||||
noiseCloseProfitCeilingPct = 2.0
|
||||
// "Hold for big moves, don't churn" regime. Live history showed the
|
||||
// account bleeding to death by fees: 0.3-0.5% in/out moves where a ~0.14%
|
||||
// round-trip fee ate 30-50% of every small winner. These values force
|
||||
// positions to be held for hours and to develop meaningful moves before
|
||||
// closing, and cut the trade frequency hard.
|
||||
autopilotMinHoldDuration = 4 * time.Hour
|
||||
autopilotNoiseCloseHoldDuration = 8 * time.Hour
|
||||
autopilotReentryCooldown = 3 * time.Hour
|
||||
// Drastically cut churn: at most a couple of new positions per hour/cycle.
|
||||
autopilotMaxOpensPerHour = 3
|
||||
autopilotMaxOpensPerCycle = 2
|
||||
// Wide, asymmetric exits. Cut a loser only at a real -5% (at 5x leverage
|
||||
// that is -25% of margin — survivable), let a winner run to +12% before
|
||||
// any early take-profit. The noise band (-4%..+6%) blocks closing on the
|
||||
// small moves that were grinding the account to nothing.
|
||||
earlyCloseStopLossBypassPct = -5.0
|
||||
earlyCloseTakeProfitBypassPct = 12.0
|
||||
noiseCloseLossFloorPct = -4.0
|
||||
noiseCloseProfitCeilingPct = 6.0
|
||||
)
|
||||
|
||||
func isOpenAction(action string) bool {
|
||||
|
||||
@@ -32,7 +32,8 @@ func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
|
||||
|
||||
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -3.0)
|
||||
// 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 != "" {
|
||||
@@ -42,7 +43,8 @@ func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
||||
|
||||
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, 0.4)
|
||||
// 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") {
|
||||
@@ -52,7 +54,8 @@ func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
||||
|
||||
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, -1.2)
|
||||
// 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 != "" {
|
||||
@@ -76,13 +79,13 @@ 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)
|
||||
// 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, 6); !strings.Contains(reason, "6 new position") {
|
||||
t.Fatalf("expected open beyond the 6-per-cycle cap to be blocked, got %q", reason)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,16 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
// Look back 7 days. GetTrades now pulls up to 2000 recent fills (UserFills)
|
||||
// and filters to this window, so a wide lookback backfills any fills missed
|
||||
// during past outages/gaps without dropping recent ones. Dedup by trade ID
|
||||
// keeps re-processing idempotent.
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Hyperliquid trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records
|
||||
trades, err := t.GetTrades(startTime, 1000)
|
||||
trades, err := t.GetTrades(startTime, 2000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
@@ -133,9 +136,43 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
}
|
||||
|
||||
logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
|
||||
|
||||
// Reconcile local OPEN rows against the exchange's live book. Without
|
||||
// this, any missed/unmatched fill leaves a zombie OPEN row that swallows
|
||||
// every later close as a "partial close" — its realized PnL then never
|
||||
// reaches the closed-trade statistics. Scoped by exchange account so rows
|
||||
// left by prior autopilot incarnations are healed too.
|
||||
if err := t.reconcilePositions(exchangeID, positionStore); err != nil {
|
||||
logger.Infof("⚠️ Position reconcile skipped: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcilePositions builds the live (symbol, side) → quantity map from the
|
||||
// exchange (core perps + xyz dex) and lets the store close/trim any local
|
||||
// OPEN rows on this exchange account the exchange no longer backs.
|
||||
func (t *HyperliquidTrader) reconcilePositions(exchangeID string, positionStore *store.PositionStore) error {
|
||||
livePositions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get live positions: %w", err)
|
||||
}
|
||||
|
||||
liveQty := make(map[string]float64, len(livePositions))
|
||||
for _, pos := range livePositions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
side, _ := pos["side"].(string)
|
||||
qty, _ := pos["positionAmt"].(float64)
|
||||
if symbol == "" || qty <= 0 {
|
||||
continue
|
||||
}
|
||||
liveQty[store.LivePositionKey(market.Normalize(symbol), side)] += qty
|
||||
}
|
||||
|
||||
_, err = positionStore.ReconcileOpenPositionsWithLive(exchangeID, liveQty)
|
||||
return err
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task
|
||||
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration, stop <-chan struct{}) {
|
||||
syncloop.Run(stop, interval, "Hyperliquid", func() error {
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hl "github.com/sonirico/go-hyperliquid"
|
||||
)
|
||||
|
||||
// GetBalance gets account balance
|
||||
@@ -548,15 +550,23 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]type
|
||||
|
||||
// GetTrades retrieves trade history from Hyperliquid
|
||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
// Use UserFillsByTime API
|
||||
// Use UserFills (returns up to 2000 recent fills) rather than
|
||||
// UserFillsByTime, which is hard-capped at 100 fills per response. At
|
||||
// this trading frequency the account exceeds 100 fills/24h, so
|
||||
// UserFillsByTime silently dropped ~20% of fills — skewing recorded PnL
|
||||
// and fees away from the exchange truth. 2000 recent fills covers many
|
||||
// days of history; we filter to startTime client-side.
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
|
||||
fills, err := t.exchange.Info().UserFills(t.ctx, hl.UserFillsParams{Address: t.walletAddr})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
||||
}
|
||||
|
||||
var trades []types.TradeRecord
|
||||
for _, fill := range fills {
|
||||
if fill.Time < startTimeMs {
|
||||
continue
|
||||
}
|
||||
price, _ := strconv.ParseFloat(fill.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
||||
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -64,7 +63,6 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Self-contained centering grid — works regardless of parent flex setup */}
|
||||
<main className="flex-1 grid lg:grid-cols-2">
|
||||
|
||||
@@ -30,8 +30,7 @@ export default function HeaderBar({
|
||||
isLoggedIn = false,
|
||||
isHomePage = false,
|
||||
currentPage,
|
||||
language = 'zh' as Language,
|
||||
onLanguageChange,
|
||||
language = 'en' as Language,
|
||||
user,
|
||||
onLogout,
|
||||
onPageChange,
|
||||
@@ -40,12 +39,10 @@ export default function HeaderBar({
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => getUserMode() ?? 'advanced'
|
||||
)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const resolvedCurrentPage =
|
||||
currentPage ?? getCurrentPageForPath(location.pathname)
|
||||
@@ -63,12 +60,6 @@ export default function HeaderBar({
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setLanguageDropdownOpen(false)
|
||||
}
|
||||
if (
|
||||
userDropdownRef.current &&
|
||||
!userDropdownRef.current.contains(event.target as Node)
|
||||
@@ -357,56 +348,7 @@ export default function HeaderBar({
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Language Toggle - Always at the rightmost */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors text-nofx-text-muted hover:bg-white/5"
|
||||
>
|
||||
<span className="text-lg">
|
||||
{language === 'zh' ? '🇨🇳' : language === 'id' ? '🇮🇩' : '🇺🇸'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{languageDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50 bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-nofx-text
|
||||
${language === 'zh' ? 'bg-nofx-gold/10' : 'hover:bg-[rgba(26,24,19,0.06)]'}`}
|
||||
>
|
||||
<span className="text-base">🇨🇳</span>
|
||||
<span className="text-sm">Chinese</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-nofx-text
|
||||
${language === 'en' ? 'bg-nofx-gold/10' : 'hover:bg-[rgba(26,24,19,0.06)]'}`}
|
||||
>
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('id')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-nofx-text
|
||||
${language === 'id' ? 'bg-nofx-gold/10' : 'hover:bg-[rgba(26,24,19,0.06)]'}`}
|
||||
>
|
||||
<span className="text-base">🇮🇩</span>
|
||||
<span className="text-sm">Bahasa</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Language switcher removed — the product UI is English-only. */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -619,28 +561,8 @@ export default function HeaderBar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account / Lang */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Lang Switcher */}
|
||||
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
||||
{['zh', 'en', 'id'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
onLanguageChange?.(lang as Language)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${
|
||||
language === lang
|
||||
? 'bg-zinc-800 text-white shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account (language switcher removed — English-only UI) */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* Auth Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
<button
|
||||
|
||||
@@ -5,7 +5,6 @@ import { invalidateSystemConfig } from '../../lib/config'
|
||||
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
||||
|
||||
const labels = {
|
||||
zh: {
|
||||
@@ -127,7 +126,6 @@ export function SetupPage() {
|
||||
{/* Blur overlay */}
|
||||
<div className="absolute inset-0 backdrop-blur-md bg-nofx-bg/60" />
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Modal card */}
|
||||
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
|
||||
|
||||
@@ -53,6 +53,12 @@ function fmtPct(n: number | undefined): string {
|
||||
if (n == null || Number.isNaN(n)) return '—'
|
||||
return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
|
||||
}
|
||||
/** Price with magnitude-aware precision: 64,416 · 184.2 · 2.3775 · 0.0067 */
|
||||
function fmtPx(n: number | undefined): string {
|
||||
if (n == null || Number.isNaN(n) || n === 0) return '—'
|
||||
const dp = n >= 1000 ? 0 : n >= 100 ? 1 : n >= 1 ? 2 : 4
|
||||
return n.toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp })
|
||||
}
|
||||
function baseLabel(raw?: string): string {
|
||||
if (!raw) return ''
|
||||
return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '')
|
||||
@@ -77,6 +83,18 @@ function fmtTime(raw?: string | number): string {
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('en-GB', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
|
||||
/** Hold duration from entry/exit epoch-ms as a compact 45m / 2h10 / 1d3h. */
|
||||
function fmtHold(entry?: number, exit?: number): string {
|
||||
if (!entry || !exit || exit <= entry) return '—'
|
||||
const mins = Math.round((exit - entry) / 60000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
if (h < 24) return m ? `${h}h${m}` : `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d${h % 24}h`
|
||||
}
|
||||
|
||||
function useTick(ms = 1000) {
|
||||
const [, set] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -500,8 +518,9 @@ export function TerminalDashboard({
|
||||
<thead>
|
||||
<tr className="tm-sc" style={{ fontSize: 9 }}>
|
||||
<td style={{ padding: '0 0 3px' }}>symbol</td>
|
||||
<td style={{ padding: '0 0 3px' }}>side</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>lev</td>
|
||||
<td style={{ padding: '0 0 3px' }}>side·lev</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>entry</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>size</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>PnL</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>return%</td>
|
||||
</tr>
|
||||
@@ -510,11 +529,13 @@ export function TerminalDashboard({
|
||||
{positions.map((p, i) => {
|
||||
const long = /long|buy/i.test(p.side)
|
||||
const win = (p.unrealized_pnl ?? 0) >= 0
|
||||
const notional = Math.abs(p.quantity ?? 0) * (p.mark_price || p.entry_price || 0)
|
||||
return (
|
||||
<tr key={`${p.symbol}-${i}`} style={{ borderTop: '1px solid var(--tm-hair)' }}>
|
||||
<td style={{ padding: '5px 0', fontWeight: 500 }}>{baseLabel(p.symbol)}</td>
|
||||
<td style={{ padding: '5px 0' }} className={long ? 'tm-up' : 'tm-dn'}>{long ? 'long' : 'short'}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-muted)' }}>{p.leverage}×</td>
|
||||
<td style={{ padding: '5px 0' }} className={long ? 'tm-up' : 'tm-dn'}>{long ? 'long' : 'short'} <span style={{ color: 'var(--tm-muted)' }}>{p.leverage}×</span></td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtPx(p.entry_price)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtUsd(notional)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{fmtUsd(p.unrealized_pnl, true)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{(p.unrealized_pnl_pct ?? 0).toFixed(2)}%</td>
|
||||
</tr>
|
||||
@@ -528,10 +549,19 @@ export function TerminalDashboard({
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
|
||||
<span className="tm-px" style={{ fontSize: 11 }}>Recent trades</span>
|
||||
<span className="tm-sc">Recent closes · symbol/side/time/pnl</span>
|
||||
<span className="tm-sc">Recent closes · symbol/side/hold/pnl</span>
|
||||
</div>
|
||||
{recentTrades.length > 0 ? (
|
||||
<table className="tm-mono" style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||
<thead>
|
||||
<tr className="tm-sc" style={{ fontSize: 9 }}>
|
||||
<td style={{ padding: '0 0 3px' }}>symbol</td>
|
||||
<td style={{ padding: '0 0 3px' }}>side</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>hold</td>
|
||||
<td style={{ padding: '0 0 3px' }}> closed</td>
|
||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>PnL</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentTrades.map((p) => {
|
||||
const win = p.realized_pnl >= 0
|
||||
@@ -539,7 +569,8 @@ export function TerminalDashboard({
|
||||
<tr key={p.id} style={{ borderTop: '1px solid var(--tm-hair)' }}>
|
||||
<td style={{ padding: '5px 0', fontWeight: 500 }}>{baseLabel(p.symbol)}</td>
|
||||
<td style={{ padding: '5px 0' }} className={p.side === 'long' || p.side === 'LONG' ? 'tm-up' : 'tm-dn'}>{p.side.toLowerCase()}</td>
|
||||
<td style={{ padding: '5px 0', color: 'var(--tm-muted)' }}>{fmtTime(p.exit_time)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtHold(p.entry_time, p.exit_time)}</td>
|
||||
<td style={{ padding: '5px 0 5px 6px', color: 'var(--tm-muted)' }}>{fmtTime(p.exit_time)}</td>
|
||||
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{fmtUsd(p.realized_pnl, true)}</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -1389,10 +1389,12 @@ export function StrategyStudioPage() {
|
||||
risk_control: defaultRisk({
|
||||
...base.ai_config?.risk_control,
|
||||
max_positions: 2,
|
||||
btc_eth_max_leverage: 10,
|
||||
altcoin_max_leverage: 10,
|
||||
btc_eth_max_position_value_ratio: 10,
|
||||
altcoin_max_position_value_ratio: 10,
|
||||
btc_eth_max_leverage: 5,
|
||||
altcoin_max_leverage: 5,
|
||||
// Few, concentrated positions held for big moves. 5x leverage so a
|
||||
// wide (-5%) stop is survivable; 2 positions × 2.5x = 5x total.
|
||||
btc_eth_max_position_value_ratio: 2.5,
|
||||
altcoin_max_position_value_ratio: 2.5,
|
||||
max_margin_usage: 1.0,
|
||||
min_confidence: 78,
|
||||
min_risk_reward_ratio: 3,
|
||||
|
||||
Reference in New Issue
Block a user