3 Commits

Author SHA1 Message Date
tinkle-community
21407030ea fix: reconcile local positions against the live exchange book — stop lost PnL
Root cause of the dashboard under-reporting: any missed or unmatched fill
(position flip, sync gap, liquidation) left a 'zombie' OPEN row. Every later
close on that symbol landed as a partial close against the zombie, so the
row never reached CLOSED and its realized PnL never entered the closed-trade
statistics — Edge Profile, realized P/L, win rate, and the AI's own
track-record context all silently under-reported.

Fix: after each Hyperliquid order sync, reconcile local OPEN rows against the
exchange's live book (core perps + xyz dex), scoped by exchange account so
rows left by prior autopilot incarnations (each relaunch mints a fresh
trader_id sharing one exchange) are healed too. Rows the exchange no longer
holds are closed with their accumulated PnL; oversized rows are trimmed to
the live quantity. Live run confirmed: 9 zombie rows closed across
incarnations, book now matches the exchange exactly.

Also removes the header language switcher (desktop + mobile) and the
login/setup LanguageSwitcher — the product UI is English-only.

New store methods GetOpenPositionsByExchange + ReconcileOpenPositionsWithLive
with cross-incarnation test coverage.
2026-07-10 20:04:57 +09:00
tinkle-community
7a66d048f3 feat: position sizing follows the strategy config — default 4x equity notional
Positions were stuck at ~equity×1.2 (~$420 on a $500 account) no matter
what the strategy said: both the manager load path and the per-cycle config
reload hardcoded vergex strategies to a 6-position × equity×1.2 book,
silently overriding the configured 10x ratio. Both overrides removed —
sizing now comes from the strategy's own RiskControl (ClampLimits bounds it,
margin auto-reduce keeps the book solvent).

Defaults aligned across all three config sources (backend default template,
quick-create preset, studio unified config): 2 positions × 4x equity
notional at 10x leverage ≈ 80% margin when both are open — concentrated per
the operator's request. Live strategy updated via relaunch; loader log
confirms maxPos=2 posRatio=4.0.
2026-07-10 19:13:12 +09:00
tinkle-community
8c8cd9b61f feat: show entry price and notional size in the live positions panel
The panel only showed symbol/side/leverage/PnL — entry and position size
are the first things a trader checks. Side and leverage share a column to
make room; prices format with magnitude-aware precision (64,187 · 184.2 ·
0.0067); size is |qty| × mark notional.
2026-07-10 18:56:15 +09:00
14 changed files with 319 additions and 118 deletions

View File

@@ -265,8 +265,10 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
c.RiskControl.MaxPositions = 2
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 10
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
// 4× equity notional per position: at 10x leverage two full positions
// use ~80% of margin — concentrated but solvent.
c.RiskControl.BTCETHMaxPositionValueRatio = 4.0
c.RiskControl.AltcoinMaxPositionValueRatio = 4.0
c.RiskControl.MaxMarginUsage = 1.0
c.RiskControl.MinConfidence = 78
c.RiskControl.MinRiskRewardRatio = 3.0

View File

@@ -60,10 +60,10 @@ func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
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.BTCETHMaxPositionValueRatio != 10 ||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 4 ||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 4 ||
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 4x equity notional (two positions ≈ 80%% margin at 10x), got risk=%+v", trendCfg.RiskControl)
}
}

View File

@@ -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 {

View File

@@ -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
View 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
}

View 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)
}
}

View File

@@ -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)
MaxPositions: 2, // Concentrated book: two full-size positions (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
BTCETHMaxPositionValueRatio: 4.0, // Per-position notional = equity × 4; at 10x two positions ≈ 80% margin
AltcoinMaxPositionValueRatio: 4.0, // Per-position notional = equity × 4; at 10x two positions ≈ 80% margin
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)

View File

@@ -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.510, 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 != "" {

View File

@@ -133,9 +133,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 {

View File

@@ -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">

View File

@@ -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

View File

@@ -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">

View File

@@ -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)$/, '')
@@ -500,8 +506,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 +517,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>

View File

@@ -1391,8 +1391,10 @@ export function StrategyStudioPage() {
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,
// 4× equity notional per position — at 10x leverage two full
// positions use ~80% of margin (concentrated but solvent)
btc_eth_max_position_value_ratio: 4,
altcoin_max_position_value_ratio: 4,
max_margin_usage: 1.0,
min_confidence: 78,
min_risk_reward_ratio: 3,