mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 22:42:52 +08:00
Compare commits
27 Commits
d84e22ab82
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472f8aaba9 | ||
|
|
861b4b0ea5 | ||
|
|
ca5dcbf4be | ||
|
|
7296b204e4 | ||
|
|
2f8c6111b1 | ||
|
|
c5277c0fb9 | ||
|
|
1b518839ab | ||
|
|
7b52ea8f78 | ||
|
|
574ddfb1ae | ||
|
|
434301cb09 | ||
|
|
05899d5afe | ||
|
|
48d7835c48 | ||
|
|
4806ee83f7 | ||
|
|
30f8f35cda | ||
|
|
2c5a5e7ab2 | ||
|
|
6195803e9e | ||
|
|
5cd62e3c3a | ||
|
|
ed3bebf287 | ||
|
|
4881c27c44 | ||
|
|
39eac5aca7 | ||
|
|
0f3e71560c | ||
|
|
eabd279d10 | ||
|
|
09b7ac9e92 | ||
|
|
dc68884559 | ||
|
|
21407030ea | ||
|
|
7a66d048f3 | ||
|
|
8c8cd9b61f |
5
.github/workflows/docker-build.yml
vendored
5
.github/workflows/docker-build.yml
vendored
@@ -17,6 +17,11 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY_GHCR: ghcr.io
|
REGISTRY_GHCR: ghcr.io
|
||||||
|
|
||||||
|
# Least privilege by default; build/push jobs that publish images declare
|
||||||
|
# their own job-level packages:write.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
name: Prepare repository metadata
|
name: Prepare repository metadata
|
||||||
|
|||||||
70
.github/workflows/pr-checks-comment.yml
vendored
70
.github/workflows/pr-checks-comment.yml
vendored
@@ -108,9 +108,17 @@ jobs:
|
|||||||
id: pr-info
|
id: pr-info
|
||||||
if: steps.backend.outputs.pr_number != '0'
|
if: steps.backend.outputs.pr_number != '0'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
# Artifact data comes from the untrusted PR workflow — always pass it
|
||||||
|
# via env (data), never template-interpolate into script source (code).
|
||||||
|
PR_NUMBER: ${{ steps.backend.outputs.pr_number }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prNumber = ${{ steps.backend.outputs.pr_number }};
|
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
||||||
|
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||||
|
core.setFailed(`Invalid PR number from artifact: ${process.env.PR_NUMBER}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get PR details
|
// Get PR details
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
@@ -154,21 +162,44 @@ jobs:
|
|||||||
- name: Post advisory results comment
|
- name: Post advisory results comment
|
||||||
if: steps.backend.outputs.pr_number != '0'
|
if: steps.backend.outputs.pr_number != '0'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
# All of these originate from the untrusted PR workflow's artifacts
|
||||||
|
# (or derive from PR content) — env-only, never inline in the script.
|
||||||
|
PR_NUMBER: ${{ steps.backend.outputs.pr_number }}
|
||||||
|
PR_TITLE: ${{ steps.pr-info.outputs.pr_title }}
|
||||||
|
TITLE_VALID: ${{ steps.pr-info.outputs.title_valid }}
|
||||||
|
PR_SIZE: ${{ steps.pr-info.outputs.pr_size }}
|
||||||
|
SIZE_EMOJI: ${{ steps.pr-info.outputs.size_emoji }}
|
||||||
|
TOTAL_LINES: ${{ steps.pr-info.outputs.total_lines }}
|
||||||
|
ADDITIONS: ${{ steps.pr-info.outputs.additions }}
|
||||||
|
DELETIONS: ${{ steps.pr-info.outputs.deletions }}
|
||||||
|
FMT_STATUS: ${{ steps.backend.outputs.fmt_status }}
|
||||||
|
VET_STATUS: ${{ steps.backend.outputs.vet_status }}
|
||||||
|
TEST_STATUS: ${{ steps.backend.outputs.test_status }}
|
||||||
|
FMT_FILES: ${{ steps.backend.outputs.fmt_files }}
|
||||||
|
VET_OUTPUT: ${{ steps.backend.outputs.vet_output }}
|
||||||
|
TEST_OUTPUT: ${{ steps.backend.outputs.test_output }}
|
||||||
|
BUILD_STATUS: ${{ steps.frontend.outputs.build_status }}
|
||||||
|
BUILD_OUTPUT: ${{ steps.frontend.outputs.build_output }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prNumber = ${{ steps.backend.outputs.pr_number }};
|
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
||||||
|
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||||
|
core.setFailed(`Invalid PR number from artifact: ${process.env.PR_NUMBER}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let comment = '## 🤖 Advisory Check Results\n\n';
|
let comment = '## 🤖 Advisory Check Results\n\n';
|
||||||
comment += 'These are **advisory** checks to help improve code quality. They won\'t block your PR from being merged.\n\n';
|
comment += 'These are **advisory** checks to help improve code quality. They won\'t block your PR from being merged.\n\n';
|
||||||
|
|
||||||
// PR Information section
|
// PR Information section
|
||||||
const prTitle = '${{ steps.pr-info.outputs.pr_title }}';
|
const prTitle = process.env.PR_TITLE || '';
|
||||||
const titleValid = '${{ steps.pr-info.outputs.title_valid }}' === 'true';
|
const titleValid = process.env.TITLE_VALID === 'true';
|
||||||
const prSize = '${{ steps.pr-info.outputs.pr_size }}';
|
const prSize = process.env.PR_SIZE || '';
|
||||||
const sizeEmoji = '${{ steps.pr-info.outputs.size_emoji }}';
|
const sizeEmoji = process.env.SIZE_EMOJI || '';
|
||||||
const totalLines = '${{ steps.pr-info.outputs.total_lines }}';
|
const totalLines = process.env.TOTAL_LINES || '0';
|
||||||
const additions = '${{ steps.pr-info.outputs.additions }}';
|
const additions = process.env.ADDITIONS || '0';
|
||||||
const deletions = '${{ steps.pr-info.outputs.deletions }}';
|
const deletions = process.env.DELETIONS || '0';
|
||||||
|
|
||||||
comment += '### 📋 PR Information\n\n';
|
comment += '### 📋 PR Information\n\n';
|
||||||
|
|
||||||
@@ -196,16 +227,16 @@ jobs:
|
|||||||
comment += '\n';
|
comment += '\n';
|
||||||
|
|
||||||
// Backend checks
|
// Backend checks
|
||||||
const fmtStatus = '${{ steps.backend.outputs.fmt_status }}';
|
const fmtStatus = process.env.FMT_STATUS || '';
|
||||||
const vetStatus = '${{ steps.backend.outputs.vet_status }}';
|
const vetStatus = process.env.VET_STATUS || '';
|
||||||
const testStatus = '${{ steps.backend.outputs.test_status }}';
|
const testStatus = process.env.TEST_STATUS || '';
|
||||||
|
|
||||||
if (fmtStatus || vetStatus || testStatus) {
|
if (fmtStatus || vetStatus || testStatus) {
|
||||||
comment += '\n### 🔧 Backend Checks\n\n';
|
comment += '\n### 🔧 Backend Checks\n\n';
|
||||||
|
|
||||||
if (fmtStatus) {
|
if (fmtStatus) {
|
||||||
comment += '**Go Formatting:** ' + fmtStatus + '\n';
|
comment += '**Go Formatting:** ' + fmtStatus + '\n';
|
||||||
const fmtFiles = `${{ steps.backend.outputs.fmt_files }}`;
|
const fmtFiles = process.env.FMT_FILES || '';
|
||||||
if (fmtFiles && fmtFiles.trim()) {
|
if (fmtFiles && fmtFiles.trim()) {
|
||||||
comment += '<details><summary>Files needing formatting</summary>\n\n```\n' + fmtFiles + '\n```\n</details>\n\n';
|
comment += '<details><summary>Files needing formatting</summary>\n\n```\n' + fmtFiles + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -213,7 +244,7 @@ jobs:
|
|||||||
|
|
||||||
if (vetStatus) {
|
if (vetStatus) {
|
||||||
comment += '**Go Vet:** ' + vetStatus + '\n';
|
comment += '**Go Vet:** ' + vetStatus + '\n';
|
||||||
const vetOutput = `${{ steps.backend.outputs.vet_output }}`;
|
const vetOutput = process.env.VET_OUTPUT || '';
|
||||||
if (vetOutput && vetOutput.trim()) {
|
if (vetOutput && vetOutput.trim()) {
|
||||||
comment += '<details><summary>Issues found</summary>\n\n```\n' + vetOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
comment += '<details><summary>Issues found</summary>\n\n```\n' + vetOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -221,7 +252,7 @@ jobs:
|
|||||||
|
|
||||||
if (testStatus) {
|
if (testStatus) {
|
||||||
comment += '**Tests:** ' + testStatus + '\n';
|
comment += '**Tests:** ' + testStatus + '\n';
|
||||||
const testOutput = `${{ steps.backend.outputs.test_output }}`;
|
const testOutput = process.env.TEST_OUTPUT || '';
|
||||||
if (testOutput && testOutput.trim()) {
|
if (testOutput && testOutput.trim()) {
|
||||||
comment += '<details><summary>Test output</summary>\n\n```\n' + testOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
comment += '<details><summary>Test output</summary>\n\n```\n' + testOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -236,13 +267,13 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Frontend checks
|
// Frontend checks
|
||||||
const buildStatus = '${{ steps.frontend.outputs.build_status }}';
|
const buildStatus = process.env.BUILD_STATUS || '';
|
||||||
|
|
||||||
if (buildStatus) {
|
if (buildStatus) {
|
||||||
comment += '\n### ⚛️ Frontend Checks\n\n';
|
comment += '\n### ⚛️ Frontend Checks\n\n';
|
||||||
|
|
||||||
comment += '**Build & Type Check:** ' + buildStatus + '\n';
|
comment += '**Build & Type Check:** ' + buildStatus + '\n';
|
||||||
const buildOutput = `${{ steps.frontend.outputs.build_output }}`;
|
const buildOutput = process.env.BUILD_OUTPUT || '';
|
||||||
if (buildOutput && buildOutput.trim()) {
|
if (buildOutput && buildOutput.trim()) {
|
||||||
comment += '<details><summary>Build output</summary>\n\n```\n' + buildOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
comment += '<details><summary>Build output</summary>\n\n```\n' + buildOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -273,6 +304,9 @@ jobs:
|
|||||||
- name: Post fallback comment if no results
|
- name: Post fallback comment if no results
|
||||||
if: steps.backend.outputs.pr_number == '0'
|
if: steps.backend.outputs.pr_number == '0'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
# Fork branch names are attacker-controlled — env, not inline.
|
||||||
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
// Try to get PR number from the workflow_run event
|
// Try to get PR number from the workflow_run event
|
||||||
@@ -280,7 +314,7 @@ jobs:
|
|||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
state: 'open',
|
state: 'open',
|
||||||
head: `${context.repo.owner}:${{ github.event.workflow_run.head_branch }}`
|
head: `${context.repo.owner}:${process.env.HEAD_BRANCH}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pulls.data.length === 0) {
|
if (pulls.data.length === 0) {
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -6,6 +6,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
|
|
||||||
|
# Least privilege: these jobs only read the repo.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-tests:
|
backend-tests:
|
||||||
name: Backend Tests
|
name: Backend Tests
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,6 +49,7 @@ web/node_modules/
|
|||||||
node_modules/
|
node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
web/.vite/
|
web/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
||||||
eslint-*.json
|
eslint-*.json
|
||||||
|
|||||||
@@ -68,14 +68,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
logger.Infof("⚠️ No AI models in database, returning defaults")
|
logger.Infof("⚠️ No AI models in database, returning defaults")
|
||||||
defaultModels := []SafeModelConfig{
|
defaultModels := []SafeModelConfig{
|
||||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
|
{ID: "claw402", Name: "Claw402 (Base USDC)", Provider: "claw402", Enabled: false, HasAPIKey: false},
|
||||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, defaultModels)
|
c.JSON(http.StatusOK, defaultModels)
|
||||||
return
|
return
|
||||||
@@ -116,14 +109,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
if len(safeModels) == 0 {
|
if len(safeModels) == 0 {
|
||||||
logger.Infof("⚠️ No visible AI models in database, returning defaults")
|
logger.Infof("⚠️ No visible AI models in database, returning defaults")
|
||||||
defaultModels := []SafeModelConfig{
|
defaultModels := []SafeModelConfig{
|
||||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
|
{ID: "claw402", Name: "Claw402 (Base USDC)", Provider: "claw402", Enabled: false, HasAPIKey: false},
|
||||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, defaultModels)
|
c.JSON(http.StatusOK, defaultModels)
|
||||||
return
|
return
|
||||||
@@ -192,7 +178,24 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
|||||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update each model's configuration and track traders that need reload
|
// Update each model's configuration and track traders that need reload.
|
||||||
|
// The request key may be either the model row id or the provider name
|
||||||
|
// (legacy clients send the provider, e.g. "claw402", while trader rows
|
||||||
|
// reference the full model id) — resolve both, mirroring the matching in
|
||||||
|
// AIModelStore.Update, otherwise running traders keep the old model.
|
||||||
|
modelIDCandidates := func(modelID string) map[string]bool {
|
||||||
|
candidates := map[string]bool{modelID: true}
|
||||||
|
if models, listErr := s.store.AIModel().List(userID); listErr == nil {
|
||||||
|
for _, m := range models {
|
||||||
|
if m.ID == modelID || m.Provider == modelID {
|
||||||
|
candidates[m.ID] = true
|
||||||
|
candidates[m.Provider] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
tradersToReload := make(map[string]bool)
|
tradersToReload := make(map[string]bool)
|
||||||
for modelID, modelData := range req.Models {
|
for modelID, modelData := range req.Models {
|
||||||
// SSRF protection: validate custom_api_url before storing
|
// SSRF protection: validate custom_api_url before storing
|
||||||
@@ -206,9 +209,11 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find traders using this AI model BEFORE updating
|
// Find traders using this AI model BEFORE updating
|
||||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
for candidateID := range modelIDCandidates(modelID) {
|
||||||
for _, t := range traders {
|
traders, _ := s.store.Trader().ListByAIModelID(userID, candidateID)
|
||||||
tradersToReload[t.ID] = true
|
for _, t := range traders {
|
||||||
|
tradersToReload[t.ID] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||||
@@ -239,17 +244,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
|||||||
func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||||
// Return static list of supported AI models with default versions
|
// Return static list of supported AI models with default versions
|
||||||
supportedModels := []map[string]interface{}{
|
supportedModels := []map[string]interface{}{
|
||||||
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
|
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "gpt-5.6"},
|
||||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
|
||||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
|
||||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
|
|
||||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
|
||||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
|
||||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
|
||||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
|
||||||
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
|
|
||||||
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
|
|
||||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, supportedModels)
|
c.JSON(http.StatusOK, supportedModels)
|
||||||
|
|||||||
@@ -51,7 +51,18 @@ type hyperliquidAccountSummary struct {
|
|||||||
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
||||||
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
||||||
OpenPositions int `json:"openPositions"`
|
OpenPositions int `json:"openPositions"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
// Spot USDC ("main wallet") balance, so the UI can offer spot->perp funding.
|
||||||
|
SpotUSDC float64 `json:"spotUsdc"`
|
||||||
|
SpotUSDCAvailable float64 `json:"spotUsdcAvailable"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hyperliquidSpotState struct {
|
||||||
|
Balances []struct {
|
||||||
|
Coin string `json:"coin"`
|
||||||
|
Total string `json:"total"`
|
||||||
|
Hold string `json:"hold"`
|
||||||
|
} `json:"balances"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type hyperliquidAgentInfo struct {
|
type hyperliquidAgentInfo struct {
|
||||||
@@ -118,43 +129,28 @@ func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestBody := map[string]any{
|
|
||||||
"type": "clearinghouseState",
|
|
||||||
"user": address,
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(requestBody)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var state hyperliquidClearinghouseState
|
var state hyperliquidClearinghouseState
|
||||||
if err := json.Unmarshal(respBody, &state); err != nil {
|
if err := postHyperliquidInfo(c, map[string]any{"type": "clearinghouseState", "user": address}, &state); err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to query Hyperliquid balance", "detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spot ("main wallet") balance is best-effort: a wallet with no spot assets
|
||||||
|
// must not break the perp summary.
|
||||||
|
var spotUSDC, spotAvailable float64
|
||||||
|
var spotState hyperliquidSpotState
|
||||||
|
if err := postHyperliquidInfo(c, map[string]any{"type": "spotClearinghouseState", "user": address}, &spotState); err == nil {
|
||||||
|
for _, balance := range spotState.Balances {
|
||||||
|
if strings.EqualFold(balance.Coin, "USDC") {
|
||||||
|
spotUSDC = parseFloatOrZero(balance.Total)
|
||||||
|
spotAvailable = spotUSDC - parseFloatOrZero(balance.Hold)
|
||||||
|
if spotAvailable < 0 {
|
||||||
|
spotAvailable = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
||||||
if accountValue == 0 {
|
if accountValue == 0 {
|
||||||
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
||||||
@@ -175,16 +171,48 @@ func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
||||||
Address: address,
|
Address: address,
|
||||||
AccountValue: accountValue,
|
AccountValue: accountValue,
|
||||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
||||||
TotalMarginUsed: marginUsed,
|
TotalMarginUsed: marginUsed,
|
||||||
UnrealizedPnl: unrealizedPnl,
|
UnrealizedPnl: unrealizedPnl,
|
||||||
OpenPositions: openPositions,
|
OpenPositions: openPositions,
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
SpotUSDC: spotUSDC,
|
||||||
|
SpotUSDCAvailable: spotAvailable,
|
||||||
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postHyperliquidInfo posts a query to the Hyperliquid info endpoint and
|
||||||
|
// decodes the JSON response into out.
|
||||||
|
func postHyperliquidInfo(c *gin.Context, requestBody map[string]any, out any) error {
|
||||||
|
body, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reach Hyperliquid: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("Hyperliquid returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
|
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
|
||||||
// including the NOFX agent's validUntil so the UI can show the expiry date and
|
// including the NOFX agent's validUntil so the UI can show the expiry date and
|
||||||
// warn before the 180-day authorization lapses.
|
// warn before the 180-day authorization lapses.
|
||||||
@@ -267,6 +295,11 @@ func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "usdClassTransfer":
|
||||||
|
if err := validateUsdClassTransferAction(req.Action); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
||||||
return
|
return
|
||||||
@@ -349,6 +382,29 @@ func validateApproveBuilderFeeAction(action map[string]any) error {
|
|||||||
return validateCommonHyperliquidSignedAction(action)
|
return validateCommonHyperliquidSignedAction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateUsdClassTransferAction guards the spot<->perp transfer relay. The
|
||||||
|
// signature is produced by the user's own wallet, so the account moved is
|
||||||
|
// always the signer's; validation only keeps malformed or SDK-style
|
||||||
|
// subaccount-suffixed amounts from reaching Hyperliquid through NOFX.
|
||||||
|
func validateUsdClassTransferAction(action map[string]any) error {
|
||||||
|
rawAmount, ok := action["amount"].(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("missing amount")
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(rawAmount)
|
||||||
|
if strings.Contains(amount, " ") {
|
||||||
|
return fmt.Errorf("invalid amount")
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(amount, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fmt.Errorf("amount must be a positive number")
|
||||||
|
}
|
||||||
|
if _, ok := action["toPerp"].(bool); !ok {
|
||||||
|
return fmt.Errorf("missing or invalid toPerp")
|
||||||
|
}
|
||||||
|
return validateCommonHyperliquidSignedAction(action)
|
||||||
|
}
|
||||||
|
|
||||||
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
||||||
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
||||||
return fmt.Errorf("invalid signatureChainId")
|
return fmt.Errorf("invalid signatureChainId")
|
||||||
|
|||||||
67
api/handler_hyperliquid_wallet_test.go
Normal file
67
api/handler_hyperliquid_wallet_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func usdClassTransferAction(overrides map[string]any) map[string]any {
|
||||||
|
action := map[string]any{
|
||||||
|
"type": "usdClassTransfer",
|
||||||
|
"signatureChainId": "0x66eee",
|
||||||
|
"hyperliquidChain": "Mainnet",
|
||||||
|
"amount": "21.5",
|
||||||
|
"toPerp": true,
|
||||||
|
"nonce": float64(1784900000000),
|
||||||
|
}
|
||||||
|
for k, v := range overrides {
|
||||||
|
if v == nil {
|
||||||
|
delete(action, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
action[k] = v
|
||||||
|
}
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUsdClassTransferActionAcceptsValidTransfer(t *testing.T) {
|
||||||
|
if err := validateUsdClassTransferAction(usdClassTransferAction(nil)); err != nil {
|
||||||
|
t.Fatalf("expected valid usdClassTransfer to pass, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUsdClassTransferActionRejectsBadAmounts(t *testing.T) {
|
||||||
|
cases := map[string]any{
|
||||||
|
"zero": "0",
|
||||||
|
"negative": "-5",
|
||||||
|
"not number": "abc",
|
||||||
|
"empty": "",
|
||||||
|
// The SDK's subaccount suffix must not be relayable from the browser.
|
||||||
|
"subaccount": "21.5 subaccount:0x1234",
|
||||||
|
}
|
||||||
|
for name, amount := range cases {
|
||||||
|
err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"amount": amount}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s: expected amount %q to be rejected", name, amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUsdClassTransferActionRequiresBooleanToPerp(t *testing.T) {
|
||||||
|
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": nil})); err == nil {
|
||||||
|
t.Fatal("expected missing toPerp to be rejected")
|
||||||
|
}
|
||||||
|
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": "true"})); err == nil {
|
||||||
|
t.Fatal("expected non-boolean toPerp to be rejected")
|
||||||
|
}
|
||||||
|
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": false})); err != nil {
|
||||||
|
t.Fatalf("expected toPerp=false (perp->spot) to be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateUsdClassTransferActionRequiresMainnetChain(t *testing.T) {
|
||||||
|
err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"hyperliquidChain": "Testnet"}))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "hyperliquidChain") {
|
||||||
|
t.Fatalf("expected Testnet chain to be rejected, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -265,8 +265,11 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
|||||||
c.RiskControl.MaxPositions = 2
|
c.RiskControl.MaxPositions = 2
|
||||||
c.RiskControl.BTCETHMaxLeverage = 10
|
c.RiskControl.BTCETHMaxLeverage = 10
|
||||||
c.RiskControl.AltcoinMaxLeverage = 10
|
c.RiskControl.AltcoinMaxLeverage = 10
|
||||||
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
|
// Few, concentrated positions held for big moves. 10x leverage keeps a
|
||||||
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
|
// wide (-5%) stop survivable (~-50% margin, ~10% liquidation cushion);
|
||||||
|
// 2 positions × 5x = 10x total notional (full margin, doubled exposure).
|
||||||
|
c.RiskControl.BTCETHMaxPositionValueRatio = 5.0
|
||||||
|
c.RiskControl.AltcoinMaxPositionValueRatio = 5.0
|
||||||
c.RiskControl.MaxMarginUsage = 1.0
|
c.RiskControl.MaxMarginUsage = 1.0
|
||||||
c.RiskControl.MinConfidence = 78
|
c.RiskControl.MinConfidence = 78
|
||||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
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" {
|
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)
|
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 {
|
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)
|
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 {
|
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)
|
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||||
}
|
}
|
||||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
|
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 5.0 ||
|
||||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
|
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 5.0 ||
|
||||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
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 5x equity notional (2 positions = 10x total at 10x), got risk=%+v", trendCfg.RiskControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -20,10 +20,9 @@ require (
|
|||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/sonirico/go-hyperliquid v0.36.0
|
github.com/sonirico/go-hyperliquid v0.36.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.55.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
golang.org/x/text v0.37.0
|
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
@@ -97,6 +96,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -233,8 +233,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfP
|
|||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
|
|||||||
@@ -215,9 +215,7 @@ 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("- 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("- 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("- 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(vergexHoldRules())
|
||||||
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")
|
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
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")
|
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 +230,7 @@ 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("- 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("- 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("- 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(vergexHoldRules())
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeModeVariant(&sb, variant, zh)
|
writeModeVariant(&sb, variant, zh)
|
||||||
@@ -259,6 +255,15 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
|||||||
// vergexCustomPromptSection returns the user's custom prompt for the vergex
|
// vergexCustomPromptSection returns the user's custom prompt for the vergex
|
||||||
// path, dropping legacy directional overrides ("long only" era) that would
|
// path, dropping legacy directional overrides ("long only" era) that would
|
||||||
// contradict the data-driven direction rule baked into this prompt.
|
// contradict the data-driven direction rule baked into this prompt.
|
||||||
|
// vergexHoldRules is the anti-churn hold/exit guidance. The numbers mirror
|
||||||
|
// the code-enforced throttle constants in trader/auto_trader_throttle.go —
|
||||||
|
// keep the two in sync when retuning.
|
||||||
|
func vergexHoldRules() string {
|
||||||
|
return "- Hold for meaningful moves, do not churn: hold new positions for at least 90 minutes; never close inside the -2%..+3% noise band before ~3 hours; after closing a symbol wait 4 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" +
|
||||||
|
"- Fees are the main edge killer: a round trip costs ~0.1% of notional. Only take setups whose realistic target is well beyond fees: stop-loss around -3% and take-profit around +8% or beyond. Do not aim for 0.2-0.3% scalps — they cannot cover fees.\n" +
|
||||||
|
"- Give positions room to develop: place stops beyond short-term noise (around -3%) and targets at meaningful heatmap resistance/liquidation zones (around +8%). Do not exit on small green or small red.\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
func vergexCustomPromptSection(section string) string {
|
func vergexCustomPromptSection(section string) string {
|
||||||
trimmed := englishOnlyPromptSection(section)
|
trimmed := englishOnlyPromptSection(section)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -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)
|
return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err)
|
||||||
}
|
}
|
||||||
strategyConfig.ClampLimits()
|
strategyConfig.ClampLimits()
|
||||||
// Autopilot (vergex_signal/claw402) runs a balanced multi-position book:
|
// Sizing comes from the strategy's own RiskControl (a hardcoded
|
||||||
// hold several instruments with a smaller per-position notional so multiple
|
// 6-position × equity×1.2 override used to live here, silently ignoring
|
||||||
// long/short positions fit the margin. Applied after ClampLimits so it is
|
// the user's configuration). ClampLimits bounds the values and the
|
||||||
// not capped back to the conservative single-position default.
|
// margin auto-reduce at order time keeps the book solvent.
|
||||||
if strategyConfig.CoinSource.SourceType == "vergex_signal" {
|
|
||||||
strategyConfig.RiskControl.MaxPositions = 6
|
|
||||||
strategyConfig.RiskControl.BTCETHMaxPositionValueRatio = 1.2
|
|
||||||
strategyConfig.RiskControl.AltcoinMaxPositionValueRatio = 1.2
|
|
||||||
}
|
|
||||||
logger.Infof("✓ Trader %s loaded strategy config: %s (maxPos=%d, posRatio=%.1f)", traderCfg.Name, strategy.Name, strategyConfig.RiskControl.MaxPositions, strategyConfig.RiskControl.AltcoinMaxPositionValueRatio)
|
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)
|
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ type Client struct {
|
|||||||
Log Logger // Exported for sub-packages
|
Log Logger // Exported for sub-packages
|
||||||
Cfg *Config // Exported for sub-packages
|
Cfg *Config // Exported for sub-packages
|
||||||
|
|
||||||
|
// LastCallSettledUSD is the actually-settled cost (USD) of the most
|
||||||
|
// recent call, reported by x402 upto gateways via response header.
|
||||||
|
// Zero when the last call carried no settlement information.
|
||||||
|
LastCallSettledUSD float64
|
||||||
|
|
||||||
|
// LastCallUsage is the token usage of the most recent streamed call.
|
||||||
|
// On SSE responses the gateway cannot deliver the settlement header
|
||||||
|
// (headers are flushed before usage is known), so callers derive the
|
||||||
|
// actual cost from this instead. Nil when the stream carried no usage.
|
||||||
|
LastCallUsage *TokenUsage
|
||||||
|
|
||||||
// Hooks are used to implement dynamic dispatch (polymorphism)
|
// Hooks are used to implement dynamic dispatch (polymorphism)
|
||||||
// When provider.DeepSeekClient embeds Client, Hooks point to DeepSeekClient
|
// When provider.DeepSeekClient embeds Client, Hooks point to DeepSeekClient
|
||||||
// This way methods called in Call() are automatically dispatched to the overridden version
|
// This way methods called in Call() are automatically dispatched to the overridden version
|
||||||
@@ -213,6 +224,18 @@ func (client *Client) SetAuthHeader(reqHeader http.Header) {
|
|||||||
reqHeader.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey))
|
reqHeader.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modelSupportsCustomTemperature reports whether the target model accepts a
|
||||||
|
// non-default temperature. OpenAI reasoning models (gpt-5 family, o-series)
|
||||||
|
// reject any value other than the default and fail the whole request with
|
||||||
|
// "unsupported_value", so temperature must be omitted for them.
|
||||||
|
func modelSupportsCustomTemperature(model string) bool {
|
||||||
|
m := strings.ToLower(model)
|
||||||
|
return !strings.HasPrefix(m, "gpt-5") &&
|
||||||
|
!strings.HasPrefix(m, "o1") &&
|
||||||
|
!strings.HasPrefix(m, "o3") &&
|
||||||
|
!strings.HasPrefix(m, "o4")
|
||||||
|
}
|
||||||
|
|
||||||
func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||||
// Build messages array
|
// Build messages array
|
||||||
messages := []map[string]string{}
|
messages := []map[string]string{}
|
||||||
@@ -242,9 +265,11 @@ func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[s
|
|||||||
|
|
||||||
// Build request body
|
// Build request body
|
||||||
requestBody := map[string]interface{}{
|
requestBody := map[string]interface{}{
|
||||||
"model": client.Model,
|
"model": client.Model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": client.Cfg.Temperature, // Use configured temperature
|
}
|
||||||
|
if modelSupportsCustomTemperature(client.Model) {
|
||||||
|
requestBody["temperature"] = client.Cfg.Temperature
|
||||||
}
|
}
|
||||||
// OpenAI newer models use max_completion_tokens instead of max_tokens
|
// OpenAI newer models use max_completion_tokens instead of max_tokens
|
||||||
if client.Provider == ProviderOpenAI {
|
if client.Provider == ProviderOpenAI {
|
||||||
@@ -655,11 +680,13 @@ func (client *Client) BuildRequestBodyFromRequest(req *Request) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add optional parameters (only add non-nil parameters)
|
// Add optional parameters (only add non-nil parameters)
|
||||||
if req.Temperature != nil {
|
if modelSupportsCustomTemperature(req.Model) {
|
||||||
requestBody["temperature"] = *req.Temperature
|
if req.Temperature != nil {
|
||||||
} else {
|
requestBody["temperature"] = *req.Temperature
|
||||||
// If not set in Request, use Client's configuration
|
} else {
|
||||||
requestBody["temperature"] = client.Cfg.Temperature
|
// If not set in Request, use Client's configuration
|
||||||
|
requestBody["temperature"] = client.Cfg.Temperature
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI newer models use max_completion_tokens instead of max_tokens
|
// OpenAI newer models use max_completion_tokens instead of max_tokens
|
||||||
|
|||||||
@@ -50,37 +50,26 @@ func shortAddr(addr string) string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultClaw402URL = "https://claw402.ai"
|
DefaultClaw402URL = "https://claw402.ai"
|
||||||
DefaultClaw402Model = "deepseek-v4-flash"
|
DefaultClaw402Model = "gpt-5.6"
|
||||||
)
|
)
|
||||||
|
|
||||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
||||||
|
// Must stay in sync with the claw402 catalog (GET /api/v1/catalog).
|
||||||
var claw402ModelEndpoints = map[string]string{
|
var claw402ModelEndpoints = map[string]string{
|
||||||
// OpenAI
|
// OpenAI
|
||||||
"gpt-5.4": "/api/v1/ai/openai/chat/5.4",
|
"gpt-5.6": "/api/v1/ai/openai/chat/5.6",
|
||||||
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro",
|
"gpt-5.6-terra": "/api/v1/ai/openai/chat/5.6-terra",
|
||||||
"gpt-5.3": "/api/v1/ai/openai/chat/5.3",
|
"gpt-5.6-luna": "/api/v1/ai/openai/chat/5.6-luna",
|
||||||
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
|
|
||||||
// Anthropic
|
// Anthropic
|
||||||
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
|
"claude-fable": "/api/v1/ai/anthropic/messages/fable",
|
||||||
|
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
|
||||||
// DeepSeek
|
// DeepSeek
|
||||||
"deepseek": "/api/v1/ai/deepseek/chat",
|
"deepseek": "/api/v1/ai/deepseek/chat",
|
||||||
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
||||||
"deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash",
|
"deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash",
|
||||||
"deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro",
|
"deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro",
|
||||||
// Qwen
|
|
||||||
"qwen-max": "/api/v1/ai/qwen/chat/max",
|
|
||||||
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
|
|
||||||
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
|
|
||||||
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
|
|
||||||
// Grok
|
|
||||||
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
|
|
||||||
// Gemini
|
|
||||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
|
||||||
// Kimi
|
|
||||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
|
||||||
// Z.AI (Zhipu)
|
// Z.AI (Zhipu)
|
||||||
"glm-5": "/api/v1/ai/zhipu/chat",
|
"glm-5": "/api/v1/ai/zhipu/chat",
|
||||||
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -100,6 +89,24 @@ type Claw402Client struct {
|
|||||||
|
|
||||||
func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client }
|
func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client }
|
||||||
|
|
||||||
|
// LastCallCostUSD reports the actually-settled cost of the most recent call:
|
||||||
|
// the gateway's X-Claw402-Settled-Usd header when available (non-streaming),
|
||||||
|
// otherwise derived from streamed token usage via the gateway's own formula —
|
||||||
|
// on SSE responses the header cannot be delivered because HTTP headers are
|
||||||
|
// flushed before usage is known. ok is false when neither source is available;
|
||||||
|
// callers should then fall back to the flat catalog price.
|
||||||
|
func (c *Claw402Client) LastCallCostUSD() (float64, bool) {
|
||||||
|
if c.Client.LastCallSettledUSD > 0 {
|
||||||
|
return c.Client.LastCallSettledUSD, true
|
||||||
|
}
|
||||||
|
if u := c.Client.LastCallUsage; u != nil && u.PromptTokens+u.CompletionTokens > 0 {
|
||||||
|
if cost, ok := store.ComputeUsageCost(c.Model, u.PromptTokens, u.CompletionTokens); ok {
|
||||||
|
return cost, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
// NewClaw402Client creates a claw402 client (backward compatible).
|
// NewClaw402Client creates a claw402 client (backward compatible).
|
||||||
func NewClaw402Client() mcp.AIClient {
|
func NewClaw402Client() mcp.AIClient {
|
||||||
return NewClaw402ClientWithOptions()
|
return NewClaw402ClientWithOptions()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -201,7 +202,35 @@ func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string
|
|||||||
return SignX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
|
return SignX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X402SettledUSDHeader carries the actually-settled cost (USD) of an upto
|
||||||
|
// call, set by the claw402 gateway on the paid response.
|
||||||
|
const X402SettledUSDHeader = "X-Claw402-Settled-Usd"
|
||||||
|
|
||||||
|
func storeRespHeader(sink []*http.Header, h http.Header) {
|
||||||
|
for _, p := range sink {
|
||||||
|
if p != nil {
|
||||||
|
*p = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureSettledUSD records the gateway-reported settled cost on the client.
|
||||||
|
// Zero when the response carried no settlement header (e.g. exact-scheme
|
||||||
|
// routes, where the flat catalog price applies instead).
|
||||||
|
func captureSettledUSD(c *mcp.Client, h http.Header) {
|
||||||
|
c.LastCallSettledUSD = 0
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v := h.Get(X402SettledUSDHeader); v != "" {
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
|
||||||
|
c.LastCallSettledUSD = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
|
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
|
||||||
|
// An optional headerSink receives the headers of the successful response.
|
||||||
func DoX402Request(
|
func DoX402Request(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
httpClient *http.Client,
|
httpClient *http.Client,
|
||||||
@@ -209,6 +238,7 @@ func DoX402Request(
|
|||||||
signFn X402SignFunc,
|
signFn X402SignFunc,
|
||||||
providerTag string,
|
providerTag string,
|
||||||
logger mcp.Logger,
|
logger mcp.Logger,
|
||||||
|
headerSink ...*http.Header,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
@@ -277,6 +307,7 @@ func DoX402Request(
|
|||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
logger.Infof("✅ [%s] Payment retry succeeded on attempt %d", providerTag, attempt)
|
logger.Infof("✅ [%s] Payment retry succeeded on attempt %d", providerTag, attempt)
|
||||||
}
|
}
|
||||||
|
storeRespHeader(headerSink, resp2.Header)
|
||||||
return body2, nil
|
return body2, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +364,7 @@ func DoX402Request(
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
storeRespHeader(headerSink, resp.Header)
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +526,8 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
c.LastCallSettledUSD = 0
|
||||||
|
c.LastCallUsage = nil
|
||||||
resp, err := DoX402RequestStream(ctx, c.HTTPClient, func() (*http.Request, error) {
|
resp, err := DoX402RequestStream(ctx, c.HTTPClient, func() (*http.Request, error) {
|
||||||
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
||||||
}, signFn, tag, c.Log)
|
}, signFn, tag, c.Log)
|
||||||
@@ -501,6 +535,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
captureSettledUSD(c, resp.Header)
|
||||||
|
|
||||||
ct := resp.Header.Get("Content-Type")
|
ct := resp.Header.Get("Content-Type")
|
||||||
c.Log.Infof("📡 [%s] Response Content-Type: %s", tag, ct)
|
c.Log.Infof("📡 [%s] Response Content-Type: %s", tag, ct)
|
||||||
@@ -544,6 +579,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
|
|||||||
|
|
||||||
text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
|
text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
|
||||||
mcp.ReportStreamUsage(usage, c.Provider, c.Model)
|
mcp.ReportStreamUsage(usage, c.Provider, c.Model)
|
||||||
|
c.LastCallUsage = usage
|
||||||
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text))
|
c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text))
|
||||||
@@ -590,12 +626,15 @@ func X402Call(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt, user
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.LastCallSettledUSD = 0
|
||||||
|
var respHeader http.Header
|
||||||
body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) {
|
body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) {
|
||||||
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
||||||
}, signFn, tag, c.Log)
|
}, signFn, tag, c.Log, &respHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
captureSettledUSD(c, respHeader)
|
||||||
return c.Hooks.ParseMCPResponse(body)
|
return c.Hooks.ParseMCPResponse(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,12 +655,15 @@ func X402CallFull(c *mcp.Client, signFn X402SignFunc, tag string, req *mcp.Reque
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.LastCallSettledUSD = 0
|
||||||
|
var respHeader http.Header
|
||||||
body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) {
|
body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) {
|
||||||
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
||||||
}, signFn, tag, c.Log)
|
}, signFn, tag, c.Log, &respHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
captureSettledUSD(c, respHeader)
|
||||||
return c.Hooks.ParseMCPResponseFull(body)
|
return c.Hooks.ParseMCPResponseFull(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,6 +706,13 @@ func SignX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt X402Ac
|
|||||||
if maxTimeout == 0 {
|
if maxTimeout == 0 {
|
||||||
maxTimeout = 300
|
maxTimeout = 300
|
||||||
}
|
}
|
||||||
|
// Echo the scheme offered by the server ("exact" or "upto") instead of
|
||||||
|
// hardcoding — under "upto" the signed amount is a cap and the gateway
|
||||||
|
// settles on actual usage.
|
||||||
|
scheme := opt.Scheme
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "exact"
|
||||||
|
}
|
||||||
|
|
||||||
resourceURL := ""
|
resourceURL := ""
|
||||||
resourceDesc := ""
|
resourceDesc := ""
|
||||||
@@ -734,7 +783,7 @@ func SignX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt X402Ac
|
|||||||
"mimeType": resourceMime,
|
"mimeType": resourceMime,
|
||||||
},
|
},
|
||||||
"accepted": map[string]interface{}{
|
"accepted": map[string]interface{}{
|
||||||
"scheme": "exact",
|
"scheme": scheme,
|
||||||
"network": network,
|
"network": network,
|
||||||
"amount": amount,
|
"amount": amount,
|
||||||
"asset": asset,
|
"asset": asset,
|
||||||
|
|||||||
2
scripts/optimize/.gitignore
vendored
Normal file
2
scripts/optimize/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.venv/
|
||||||
|
data/
|
||||||
180
scripts/optimize/extract.py
Normal file
180
scripts/optimize/extract.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""Extract a replayable dataset from data/data.db.
|
||||||
|
|
||||||
|
Reads decision_records and emits three CSVs under scripts/optimize/data/:
|
||||||
|
cycles.csv - one row per decision cycle (timestamp, recorded equity)
|
||||||
|
decisions.csv - the AI's intended actions per cycle (including throttled ones)
|
||||||
|
candles.csv - deduplicated per-symbol 15m OHLCV parsed from input prompts
|
||||||
|
prices.csv - per-cycle current_price for every symbol present in the prompt
|
||||||
|
|
||||||
|
Run: .venv/bin/python extract.py [--db ../../data/data.db]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
SECTION_RE = re.compile(r"^=== (\S+) Market Data ===$")
|
||||||
|
CURRENT_PRICE_RE = re.compile(r"^current_price = ([\d.eE+-]+)$")
|
||||||
|
CANDLE_RE = re.compile(
|
||||||
|
r"^(\d{2})-(\d{2}) (\d{2}):(\d{2})\s+"
|
||||||
|
r"([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s+([\d.eE+-]+)\s*$"
|
||||||
|
)
|
||||||
|
EQUITY_RE = re.compile(r"Account: Equity ([\d.]+)")
|
||||||
|
TS_RE = re.compile(r"(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cycle_ts(raw):
|
||||||
|
m = TS_RE.search(raw)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return datetime.strptime(
|
||||||
|
f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M:%S"
|
||||||
|
).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def candle_ts(cycle_ts, month, day, hour, minute):
|
||||||
|
"""Candle rows carry no year; anchor to the cycle timestamp."""
|
||||||
|
ts = datetime(cycle_ts.year, month, day, hour, minute, tzinfo=timezone.utc)
|
||||||
|
if ts > cycle_ts + timedelta(days=2):
|
||||||
|
ts = ts.replace(year=cycle_ts.year - 1)
|
||||||
|
return ts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_prompt(prompt, cycle_ts):
|
||||||
|
equity = None
|
||||||
|
m = EQUITY_RE.search(prompt)
|
||||||
|
if m:
|
||||||
|
equity = float(m.group(1))
|
||||||
|
|
||||||
|
prices = {}
|
||||||
|
candles = []
|
||||||
|
symbol = None
|
||||||
|
for line in prompt.splitlines():
|
||||||
|
line = line.rstrip()
|
||||||
|
sm = SECTION_RE.match(line)
|
||||||
|
if sm:
|
||||||
|
symbol = sm.group(1).upper()
|
||||||
|
continue
|
||||||
|
if symbol is None:
|
||||||
|
continue
|
||||||
|
pm = CURRENT_PRICE_RE.match(line)
|
||||||
|
if pm:
|
||||||
|
prices[symbol] = float(pm.group(1))
|
||||||
|
continue
|
||||||
|
cm = CANDLE_RE.match(line)
|
||||||
|
if cm:
|
||||||
|
ts = candle_ts(cycle_ts, *(int(cm.group(i)) for i in range(1, 5)))
|
||||||
|
o, h, low, c, v = (float(cm.group(i)) for i in range(5, 10))
|
||||||
|
candles.append((symbol, ts, o, h, low, c, v))
|
||||||
|
return equity, prices, candles
|
||||||
|
|
||||||
|
|
||||||
|
def parse_decisions(raw):
|
||||||
|
try:
|
||||||
|
items = json.loads(raw or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for d in items if isinstance(items, list) else []:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"action": str(d.get("action", "")).strip().lower(),
|
||||||
|
"symbol": str(d.get("symbol", "")).strip().upper(),
|
||||||
|
"leverage": d.get("leverage") or 0,
|
||||||
|
"price": d.get("price") or 0.0,
|
||||||
|
"stop_loss": d.get("stop_loss") or 0.0,
|
||||||
|
"take_profit": d.get("take_profit") or 0.0,
|
||||||
|
"confidence": d.get("confidence") or 0,
|
||||||
|
"recorded_success": bool(d.get("success")),
|
||||||
|
"recorded_error": str(d.get("error", "")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ap.add_argument("--db", default=os.path.join(here, "..", "..", "data", "data.db"))
|
||||||
|
ap.add_argument("--out", default=os.path.join(here, "data"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, timestamp, input_prompt, decisions FROM decision_records"
|
||||||
|
" ORDER BY timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
cycles, decisions = [], []
|
||||||
|
candle_map = {} # (symbol, ts) -> row, latest observation wins
|
||||||
|
price_rows = []
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for rec_id, ts_raw, prompt, decisions_raw in rows:
|
||||||
|
cycle_ts = parse_cycle_ts(str(ts_raw))
|
||||||
|
if cycle_ts is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
equity, prices, candles = parse_prompt(prompt or "", cycle_ts)
|
||||||
|
cycles.append((rec_id, cycle_ts.isoformat(), equity if equity is not None else ""))
|
||||||
|
for sym, price in prices.items():
|
||||||
|
price_rows.append((rec_id, cycle_ts.isoformat(), sym, price))
|
||||||
|
for sym, cts, o, h, low, c, v in candles:
|
||||||
|
candle_map[(sym, cts)] = (sym, cts.isoformat(), o, h, low, c, v)
|
||||||
|
for d in parse_decisions(decisions_raw):
|
||||||
|
decisions.append(
|
||||||
|
(
|
||||||
|
rec_id,
|
||||||
|
cycle_ts.isoformat(),
|
||||||
|
d["action"],
|
||||||
|
d["symbol"],
|
||||||
|
d["leverage"],
|
||||||
|
d["price"],
|
||||||
|
d["stop_loss"],
|
||||||
|
d["take_profit"],
|
||||||
|
d["confidence"],
|
||||||
|
int(d["recorded_success"]),
|
||||||
|
d["recorded_error"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def write(name, header, data):
|
||||||
|
path = os.path.join(args.out, name)
|
||||||
|
with open(path, "w", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(header)
|
||||||
|
w.writerows(data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
write("cycles.csv", ["cycle_id", "ts", "equity"], cycles)
|
||||||
|
write(
|
||||||
|
"decisions.csv",
|
||||||
|
[
|
||||||
|
"cycle_id", "ts", "action", "symbol", "leverage", "price",
|
||||||
|
"stop_loss", "take_profit", "confidence", "recorded_success",
|
||||||
|
"recorded_error",
|
||||||
|
],
|
||||||
|
decisions,
|
||||||
|
)
|
||||||
|
write(
|
||||||
|
"candles.csv",
|
||||||
|
["symbol", "ts", "open", "high", "low", "close", "volume"],
|
||||||
|
sorted(candle_map.values(), key=lambda r: (r[0], r[1])),
|
||||||
|
)
|
||||||
|
write("prices.csv", ["cycle_id", "ts", "symbol", "price"], price_rows)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"cycles={len(cycles)} decisions={len(decisions)}"
|
||||||
|
f" candles={len(candle_map)} prices={len(price_rows)} skipped={skipped}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
137
scripts/optimize/search.py
Normal file
137
scripts/optimize/search.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Multivariate TPE Bayesian search over NOFX autopilot risk/throttle params.
|
||||||
|
|
||||||
|
Optimizes on a train window (first ~70% of history) and reports the untouched
|
||||||
|
holdout window, plus full-period metrics and baselines for reference.
|
||||||
|
|
||||||
|
Run: .venv/bin/python search.py [--trials 800]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import optuna
|
||||||
|
|
||||||
|
from simulate import Params, Simulator, load_dataset
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_params(trial):
|
||||||
|
min_hold = trial.suggest_float("min_hold_h", 0.0, 8.0)
|
||||||
|
return Params(
|
||||||
|
min_confidence=trial.suggest_int("min_confidence", 70, 95),
|
||||||
|
min_hold_h=min_hold,
|
||||||
|
noise_hold_extra_h=trial.suggest_float("noise_hold_extra_h", 0.0, 12.0),
|
||||||
|
reentry_h=trial.suggest_float("reentry_h", 0.0, 6.0),
|
||||||
|
max_opens_per_hour=trial.suggest_int("max_opens_per_hour", 1, 6),
|
||||||
|
max_opens_per_cycle=trial.suggest_int("max_opens_per_cycle", 1, 3),
|
||||||
|
max_positions=trial.suggest_int("max_positions", 1, 4),
|
||||||
|
leverage=trial.suggest_int("leverage", 3, 20),
|
||||||
|
ratio=trial.suggest_float("ratio", 1.0, 6.0),
|
||||||
|
sl_bypass=trial.suggest_float("sl_bypass", -60.0, -5.0),
|
||||||
|
tp_bypass=trial.suggest_float("tp_bypass", 5.0, 60.0),
|
||||||
|
noise_floor=trial.suggest_float("noise_floor", -30.0, -2.0),
|
||||||
|
noise_ceiling=trial.suggest_float("noise_ceiling", 2.0, 30.0),
|
||||||
|
sl_mult=trial.suggest_float("sl_mult", 0.5, 2.5),
|
||||||
|
tp_mult=trial.suggest_float("tp_mult", 0.5, 2.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score(metrics):
|
||||||
|
if metrics is None:
|
||||||
|
return -1000.0
|
||||||
|
if metrics["bankrupt"]:
|
||||||
|
return -1000.0
|
||||||
|
return metrics["ret_pct"] - 0.5 * metrics["max_dd_pct"]
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(metrics):
|
||||||
|
if metrics is None:
|
||||||
|
return "n/a"
|
||||||
|
return (
|
||||||
|
f"ret {metrics['ret_pct']:+7.1f}% | dd {metrics['max_dd_pct']:5.1f}%"
|
||||||
|
f" | sharpe {metrics['sharpe']:+5.2f} | trades {metrics['trades']:4d}"
|
||||||
|
f" | win {metrics['win_rate']:4.1f}% | fees ${metrics['fees']:.0f}"
|
||||||
|
f" | liq {metrics['liquidations']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--trials", type=int, default=800)
|
||||||
|
ap.add_argument("--seed", type=int, default=42)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
dataset = load_dataset()
|
||||||
|
sim = Simulator(dataset)
|
||||||
|
cycles = dataset[0]
|
||||||
|
t0, t1 = cycles[0][1], cycles[-1][1]
|
||||||
|
split = t0 + (t1 - t0) * 0.7
|
||||||
|
print(f"history {t0:%Y-%m-%d} .. {t1:%Y-%m-%d}, holdout from {split:%Y-%m-%d}")
|
||||||
|
|
||||||
|
def objective(trial):
|
||||||
|
p = suggest_params(trial)
|
||||||
|
return score(sim.run(p, end=split))
|
||||||
|
|
||||||
|
sampler = optuna.samplers.TPESampler(
|
||||||
|
multivariate=True, group=True, seed=args.seed, n_startup_trials=60
|
||||||
|
)
|
||||||
|
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
||||||
|
study = optuna.create_study(direction="maximize", sampler=sampler)
|
||||||
|
study.optimize(objective, n_trials=args.trials, show_progress_bar=True)
|
||||||
|
|
||||||
|
best = Params(**study.best_params)
|
||||||
|
baselines = {
|
||||||
|
"live config (2x5x @10x)": Params(),
|
||||||
|
"old aggressive (4x5x @20x)": Params(
|
||||||
|
leverage=20, ratio=5.0, max_positions=4, min_hold_h=1.0,
|
||||||
|
noise_hold_extra_h=0.5, reentry_h=0.5, max_opens_per_hour=6,
|
||||||
|
max_opens_per_cycle=3, sl_bypass=-2.5, tp_bypass=5.0,
|
||||||
|
noise_floor=-1.0, noise_ceiling=2.0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n=== best params (train objective"
|
||||||
|
f" {study.best_value:+.1f}) ===")
|
||||||
|
for k, v in sorted(study.best_params.items()):
|
||||||
|
print(f" {k:22s} = {v:.2f}" if isinstance(v, float) else
|
||||||
|
f" {k:22s} = {v}")
|
||||||
|
|
||||||
|
print("\n=== best params performance ===")
|
||||||
|
print(" train :", fmt(sim.run(best, end=split)))
|
||||||
|
print(" holdout:", fmt(sim.run(best, start=split)))
|
||||||
|
print(" full :", fmt(sim.run(best)))
|
||||||
|
|
||||||
|
print("\n=== baselines ===")
|
||||||
|
for name, p in baselines.items():
|
||||||
|
print(f" {name}")
|
||||||
|
print(" train :", fmt(sim.run(p, end=split)))
|
||||||
|
print(" holdout:", fmt(sim.run(p, start=split)))
|
||||||
|
|
||||||
|
top = sorted(
|
||||||
|
(t for t in study.trials if t.value is not None),
|
||||||
|
key=lambda t: t.value, reverse=True
|
||||||
|
)[:10]
|
||||||
|
print("\n=== top-10 trials: holdout robustness ===")
|
||||||
|
for t in top:
|
||||||
|
m = sim.run(Params(**t.params), start=split)
|
||||||
|
print(f" train {t.value:+7.1f} | holdout {fmt(m)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
imp = optuna.importance.get_param_importances(study)
|
||||||
|
print("\n=== param importances ===")
|
||||||
|
for k, v in imp.items():
|
||||||
|
print(f" {k:22s} {v:.3f}")
|
||||||
|
except Exception as e: # sklearn not installed etc.
|
||||||
|
print(f"\n(param importances unavailable: {e})")
|
||||||
|
|
||||||
|
out = os.path.join(HERE, "data", "best_params.json")
|
||||||
|
with open(out, "w") as f:
|
||||||
|
json.dump(study.best_params, f, indent=2)
|
||||||
|
print(f"\nbest params saved to {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
339
scripts/optimize/simulate.py
Normal file
339
scripts/optimize/simulate.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""Replay recorded AI decisions under alternative risk/throttle parameters.
|
||||||
|
|
||||||
|
Mirrors the live semantics of trader/auto_trader_throttle.go:
|
||||||
|
- throttle thresholds compare PRICE-basis PnL% (leverage-independent),
|
||||||
|
matching the 2026-07-24 fix that converted the live thresholds from
|
||||||
|
margin basis to price basis
|
||||||
|
- stop-loss / take-profit are exchange trigger orders -> intra-candle fills
|
||||||
|
- opens are gated by confidence, per-cycle/per-hour caps, re-entry cooldown,
|
||||||
|
max positions and available margin
|
||||||
|
|
||||||
|
Known limitation: decisions are replayed as recorded. Parameters that would have
|
||||||
|
changed WHAT the AI decided (prompt wording, candidate pool) are out of scope;
|
||||||
|
parameters that gate/size/exit those decisions are faithfully simulated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bisect
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DATA = os.path.join(HERE, "data")
|
||||||
|
|
||||||
|
FEE_RATE = 0.000784 # measured from trader_fills: 7.84 bps per side
|
||||||
|
MIN_POSITION_USD = 12.0
|
||||||
|
LIQUIDATION_MARGIN_PNL = -0.90 # liquidate when margin PnL <= -90%
|
||||||
|
DATA_GAP_FORCE_CLOSE = timedelta(hours=48)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Params:
|
||||||
|
min_confidence: float = 78.0
|
||||||
|
min_hold_h: float = 4.0
|
||||||
|
noise_hold_extra_h: float = 4.0 # noise window = min_hold + extra
|
||||||
|
reentry_h: float = 3.0
|
||||||
|
max_opens_per_hour: int = 3
|
||||||
|
max_opens_per_cycle: int = 2
|
||||||
|
max_positions: int = 2
|
||||||
|
leverage: float = 10.0
|
||||||
|
ratio: float = 5.0 # per-position notional = ratio x equity
|
||||||
|
sl_bypass: float = -5.0 # price-PnL% allowing early AI close
|
||||||
|
tp_bypass: float = 12.0
|
||||||
|
noise_floor: float = -4.0 # price-PnL% band blocking flat closes
|
||||||
|
noise_ceiling: float = 6.0
|
||||||
|
sl_mult: float = 1.0 # scale AI stop distance from entry
|
||||||
|
tp_mult: float = 1.0
|
||||||
|
margin_cap: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
symbol: str
|
||||||
|
side: str # "long" | "short"
|
||||||
|
entry: float
|
||||||
|
notional: float # USD at entry
|
||||||
|
qty: float
|
||||||
|
margin: float
|
||||||
|
entry_ts: datetime
|
||||||
|
stop: float = 0.0
|
||||||
|
take: float = 0.0
|
||||||
|
last_price: float = 0.0
|
||||||
|
last_seen: datetime = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trade:
|
||||||
|
symbol: str
|
||||||
|
side: str
|
||||||
|
entry_ts: datetime
|
||||||
|
exit_ts: datetime
|
||||||
|
entry: float
|
||||||
|
exit: float
|
||||||
|
notional: float
|
||||||
|
pnl: float # net of fees
|
||||||
|
fees: float
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(s):
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
|
||||||
|
|
||||||
|
def load_dataset():
|
||||||
|
cycles = []
|
||||||
|
with open(os.path.join(DATA, "cycles.csv")) as f:
|
||||||
|
for r in csv.DictReader(f):
|
||||||
|
cycles.append(
|
||||||
|
(int(r["cycle_id"]), _ts(r["ts"]),
|
||||||
|
float(r["equity"]) if r["equity"] else None)
|
||||||
|
)
|
||||||
|
cycles.sort(key=lambda c: c[1])
|
||||||
|
|
||||||
|
decisions = {}
|
||||||
|
with open(os.path.join(DATA, "decisions.csv")) as f:
|
||||||
|
for r in csv.DictReader(f):
|
||||||
|
decisions.setdefault(int(r["cycle_id"]), []).append(
|
||||||
|
{
|
||||||
|
"action": r["action"],
|
||||||
|
"symbol": r["symbol"],
|
||||||
|
"price": float(r["price"]),
|
||||||
|
"stop_loss": float(r["stop_loss"]),
|
||||||
|
"take_profit": float(r["take_profit"]),
|
||||||
|
"confidence": float(r["confidence"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
prices = {}
|
||||||
|
with open(os.path.join(DATA, "prices.csv")) as f:
|
||||||
|
for r in csv.DictReader(f):
|
||||||
|
prices[(int(r["cycle_id"]), r["symbol"])] = float(r["price"])
|
||||||
|
|
||||||
|
candles = {}
|
||||||
|
with open(os.path.join(DATA, "candles.csv")) as f:
|
||||||
|
for r in csv.DictReader(f):
|
||||||
|
candles.setdefault(r["symbol"], []).append(
|
||||||
|
(_ts(r["ts"]), float(r["open"]), float(r["high"]),
|
||||||
|
float(r["low"]), float(r["close"]))
|
||||||
|
)
|
||||||
|
for sym in candles:
|
||||||
|
candles[sym].sort(key=lambda c: c[0])
|
||||||
|
candle_times = {s: [c[0] for c in rows] for s, rows in candles.items()}
|
||||||
|
return cycles, decisions, prices, candles, candle_times
|
||||||
|
|
||||||
|
|
||||||
|
class Simulator:
|
||||||
|
def __init__(self, dataset, start_equity=None):
|
||||||
|
self.cycles, self.decisions, self.prices, self.candles, self.candle_times = dataset
|
||||||
|
recorded = next((e for _, _, e in self.cycles if e), 100.0)
|
||||||
|
self.start_equity = start_equity if start_equity is not None else recorded
|
||||||
|
|
||||||
|
def price_at(self, symbol, cycle_id, ts):
|
||||||
|
p = self.prices.get((cycle_id, symbol))
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
times = self.candle_times.get(symbol)
|
||||||
|
if not times:
|
||||||
|
return None
|
||||||
|
i = bisect.bisect_right(times, ts) - 1
|
||||||
|
return self.candles[symbol][i][4] if i >= 0 else None
|
||||||
|
|
||||||
|
def price_pnl_pct(self, pos, price):
|
||||||
|
move = (price - pos.entry) / pos.entry
|
||||||
|
if pos.side == "short":
|
||||||
|
move = -move
|
||||||
|
return move * 100.0
|
||||||
|
|
||||||
|
def run(self, p: Params, start=None, end=None):
|
||||||
|
equity = self.start_equity
|
||||||
|
positions = {}
|
||||||
|
open_times = [] # for the opens-per-hour cap
|
||||||
|
last_close = {} # symbol -> ts
|
||||||
|
trades = []
|
||||||
|
curve = []
|
||||||
|
min_hold = timedelta(hours=p.min_hold_h)
|
||||||
|
noise_hold = timedelta(hours=p.min_hold_h + p.noise_hold_extra_h)
|
||||||
|
reentry = timedelta(hours=p.reentry_h)
|
||||||
|
|
||||||
|
cycles = [c for c in self.cycles
|
||||||
|
if (start is None or c[1] >= start) and (end is None or c[1] < end)]
|
||||||
|
if not cycles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close_position(pos, price, ts, reason):
|
||||||
|
nonlocal equity
|
||||||
|
move = (price - pos.entry) / pos.entry
|
||||||
|
if pos.side == "short":
|
||||||
|
move = -move
|
||||||
|
gross = pos.notional * move
|
||||||
|
fees = (pos.notional + pos.qty * price) * FEE_RATE
|
||||||
|
equity += gross - fees
|
||||||
|
trades.append(Trade(pos.symbol, pos.side, pos.entry_ts, ts,
|
||||||
|
pos.entry, price, pos.notional, gross - fees,
|
||||||
|
fees, reason))
|
||||||
|
del positions[pos.symbol]
|
||||||
|
last_close[pos.symbol] = ts
|
||||||
|
|
||||||
|
for idx, (cycle_id, ts, _) in enumerate(cycles):
|
||||||
|
next_ts = cycles[idx + 1][1] if idx + 1 < len(cycles) else ts
|
||||||
|
|
||||||
|
# 1. Candle window since previous cycle: trigger orders + liquidation
|
||||||
|
prev_ts = cycles[idx - 1][1] if idx > 0 else ts - timedelta(minutes=30)
|
||||||
|
for pos in list(positions.values()):
|
||||||
|
times = self.candle_times.get(pos.symbol, [])
|
||||||
|
lo = bisect.bisect_right(times, prev_ts)
|
||||||
|
hi = bisect.bisect_right(times, ts)
|
||||||
|
for cts, o, h, low, c in self.candles.get(pos.symbol, [])[lo:hi]:
|
||||||
|
pos.last_price, pos.last_seen = c, cts
|
||||||
|
liq_move = -(1.0 / p.leverage) * (-LIQUIDATION_MARGIN_PNL)
|
||||||
|
if pos.side == "long":
|
||||||
|
liq_px = pos.entry * (1 + liq_move)
|
||||||
|
if low <= liq_px:
|
||||||
|
close_position(pos, liq_px, cts, "liquidation"); break
|
||||||
|
if pos.stop and low <= pos.stop:
|
||||||
|
close_position(pos, pos.stop, cts, "stop_loss"); break
|
||||||
|
if pos.take and h >= pos.take:
|
||||||
|
close_position(pos, pos.take, cts, "take_profit"); break
|
||||||
|
else:
|
||||||
|
liq_px = pos.entry * (1 - liq_move)
|
||||||
|
if h >= liq_px:
|
||||||
|
close_position(pos, liq_px, cts, "liquidation"); break
|
||||||
|
if pos.stop and h >= pos.stop:
|
||||||
|
close_position(pos, pos.stop, cts, "stop_loss"); break
|
||||||
|
if pos.take and low <= pos.take:
|
||||||
|
close_position(pos, pos.take, cts, "take_profit"); break
|
||||||
|
|
||||||
|
# Stale market data: force-close what we can no longer price
|
||||||
|
for pos in list(positions.values()):
|
||||||
|
if pos.last_seen and ts - pos.last_seen > DATA_GAP_FORCE_CLOSE:
|
||||||
|
close_position(pos, pos.last_price, ts, "data_gap")
|
||||||
|
|
||||||
|
if equity <= 0:
|
||||||
|
return self._metrics(equity, trades, curve, bankrupt=True)
|
||||||
|
|
||||||
|
# 2. Replay this cycle's AI intents
|
||||||
|
opens_this_cycle = 0
|
||||||
|
for d in self.decisions.get(cycle_id, []):
|
||||||
|
sym, act = d["symbol"], d["action"]
|
||||||
|
if act in ("close_long", "close_short"):
|
||||||
|
pos = positions.get(sym)
|
||||||
|
side = "long" if act == "close_long" else "short"
|
||||||
|
if not pos or pos.side != side:
|
||||||
|
continue
|
||||||
|
price = self.price_at(sym, cycle_id, ts) or pos.last_price
|
||||||
|
if not price:
|
||||||
|
continue
|
||||||
|
pnl_pct = self.price_pnl_pct(pos, price)
|
||||||
|
held = ts - pos.entry_ts
|
||||||
|
if held >= min_hold:
|
||||||
|
allowed = (held >= noise_hold
|
||||||
|
or pnl_pct <= p.noise_floor
|
||||||
|
or pnl_pct >= p.noise_ceiling)
|
||||||
|
else:
|
||||||
|
allowed = pnl_pct <= p.sl_bypass or pnl_pct >= p.tp_bypass
|
||||||
|
if allowed:
|
||||||
|
close_position(pos, price, ts, "ai_close")
|
||||||
|
|
||||||
|
elif act in ("open_long", "open_short"):
|
||||||
|
if d["confidence"] < p.min_confidence:
|
||||||
|
continue
|
||||||
|
if opens_this_cycle >= p.max_opens_per_cycle:
|
||||||
|
continue
|
||||||
|
if sym in positions or len(positions) >= p.max_positions:
|
||||||
|
continue
|
||||||
|
hour_ago = ts - timedelta(hours=1)
|
||||||
|
open_times[:] = [t for t in open_times if t >= hour_ago]
|
||||||
|
if len(open_times) >= p.max_opens_per_hour:
|
||||||
|
continue
|
||||||
|
if sym in last_close and ts - last_close[sym] < reentry:
|
||||||
|
continue
|
||||||
|
price = self.price_at(sym, cycle_id, ts) or d["price"]
|
||||||
|
if not price or price <= 0:
|
||||||
|
continue
|
||||||
|
notional = p.ratio * equity
|
||||||
|
margin_used = sum(x.margin for x in positions.values())
|
||||||
|
margin_free = p.margin_cap * equity - margin_used
|
||||||
|
notional = min(notional, max(0.0, margin_free) * p.leverage)
|
||||||
|
if notional < MIN_POSITION_USD:
|
||||||
|
continue
|
||||||
|
side = "long" if act == "open_long" else "short"
|
||||||
|
stop = take = 0.0
|
||||||
|
if d["stop_loss"] > 0:
|
||||||
|
stop = price - (price - d["stop_loss"]) * p.sl_mult \
|
||||||
|
if side == "long" else \
|
||||||
|
price + (d["stop_loss"] - price) * p.sl_mult
|
||||||
|
if (side == "long") != (stop < price):
|
||||||
|
stop = 0.0
|
||||||
|
if d["take_profit"] > 0:
|
||||||
|
take = price + (d["take_profit"] - price) * p.tp_mult \
|
||||||
|
if side == "long" else \
|
||||||
|
price - (price - d["take_profit"]) * p.tp_mult
|
||||||
|
if (side == "long") != (take > price):
|
||||||
|
take = 0.0
|
||||||
|
equity -= notional * FEE_RATE
|
||||||
|
positions[sym] = Position(
|
||||||
|
symbol=sym, side=side, entry=price, notional=notional,
|
||||||
|
qty=notional / price, margin=notional / p.leverage,
|
||||||
|
entry_ts=ts, stop=stop, take=take,
|
||||||
|
last_price=price, last_seen=ts,
|
||||||
|
)
|
||||||
|
open_times.append(ts)
|
||||||
|
opens_this_cycle += 1
|
||||||
|
|
||||||
|
# 3. Mark to market
|
||||||
|
mtm = equity
|
||||||
|
for pos in positions.values():
|
||||||
|
price = self.price_at(pos.symbol, cycle_id, ts) or pos.last_price
|
||||||
|
move = (price - pos.entry) / pos.entry
|
||||||
|
if pos.side == "short":
|
||||||
|
move = -move
|
||||||
|
mtm += pos.notional * move
|
||||||
|
curve.append((ts, mtm))
|
||||||
|
if mtm <= 0:
|
||||||
|
return self._metrics(equity, trades, curve, bankrupt=True)
|
||||||
|
|
||||||
|
for pos in list(positions.values()):
|
||||||
|
close_position(pos, pos.last_price or pos.entry,
|
||||||
|
cycles[-1][1], "end_of_data")
|
||||||
|
curve.append((cycles[-1][1], equity))
|
||||||
|
return self._metrics(equity, trades, curve, bankrupt=False)
|
||||||
|
|
||||||
|
def _metrics(self, equity, trades, curve, bankrupt):
|
||||||
|
peak, max_dd = -1e18, 0.0
|
||||||
|
for _, v in curve:
|
||||||
|
peak = max(peak, v)
|
||||||
|
if peak > 0:
|
||||||
|
max_dd = max(max_dd, (peak - v) / peak)
|
||||||
|
daily = {}
|
||||||
|
for ts, v in curve:
|
||||||
|
daily[ts.date()] = v
|
||||||
|
vals = list(daily.values())
|
||||||
|
rets = [(b - a) / a for a, b in zip(vals, vals[1:]) if a > 0]
|
||||||
|
sharpe = 0.0
|
||||||
|
if len(rets) > 1:
|
||||||
|
mean = sum(rets) / len(rets)
|
||||||
|
var = sum((r - mean) ** 2 for r in rets) / (len(rets) - 1)
|
||||||
|
if var > 0:
|
||||||
|
sharpe = mean / var ** 0.5 * (365 ** 0.5)
|
||||||
|
wins = sum(1 for t in trades if t.pnl > 0)
|
||||||
|
return {
|
||||||
|
"final_equity": equity,
|
||||||
|
"net_pnl": equity - self.start_equity,
|
||||||
|
"ret_pct": (equity / self.start_equity - 1) * 100,
|
||||||
|
"max_dd_pct": max_dd * 100,
|
||||||
|
"sharpe": sharpe,
|
||||||
|
"trades": len(trades),
|
||||||
|
"win_rate": wins / len(trades) * 100 if trades else 0.0,
|
||||||
|
"fees": sum(t.fees for t in trades),
|
||||||
|
"liquidations": sum(1 for t in trades if t.reason == "liquidation"),
|
||||||
|
"bankrupt": bankrupt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sim = Simulator(load_dataset())
|
||||||
|
live = Params()
|
||||||
|
m = sim.run(live)
|
||||||
|
print("live-config replay:", {k: round(v, 2) if isinstance(v, float) else v
|
||||||
|
for k, v in m.items()})
|
||||||
@@ -24,18 +24,12 @@ var modelPrices = map[string]float64{
|
|||||||
"deepseek-reasoner": 0.005,
|
"deepseek-reasoner": 0.005,
|
||||||
"deepseek-v4-flash": 0.003,
|
"deepseek-v4-flash": 0.003,
|
||||||
"deepseek-v4-pro": 0.01,
|
"deepseek-v4-pro": 0.01,
|
||||||
"gpt-5.4": 0.05,
|
"gpt-5.6": 0.06,
|
||||||
"gpt-5.4-pro": 0.50,
|
"gpt-5.6-terra": 0.03,
|
||||||
"gpt-5.3": 0.01,
|
"gpt-5.6-luna": 0.012,
|
||||||
"gpt-5-mini": 0.005,
|
"claude-fable": 0.24,
|
||||||
"claude-opus": 0.12,
|
"claude-opus": 0.12,
|
||||||
"qwen-max": 0.01,
|
"glm-5": 0.003,
|
||||||
"qwen-plus": 0.005,
|
|
||||||
"qwen-turbo": 0.002,
|
|
||||||
"qwen-flash": 0.002,
|
|
||||||
"grok-4.1": 0.06,
|
|
||||||
"gemini-3.1-pro": 0.03,
|
|
||||||
"kimi-k2.5": 0.008,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetModelPrice returns the price per call for a given model
|
// GetModelPrice returns the price per call for a given model
|
||||||
@@ -46,6 +40,45 @@ func GetModelPrice(model string) float64 {
|
|||||||
return 0.01 // default fallback
|
return 0.01 // default fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modelTokenPrices maps model → USD per 1M input/output tokens, mirroring the
|
||||||
|
// claw402 gateway pricing config (providers/*.yaml). Used to derive the actual
|
||||||
|
// upto-settled cost from streamed token usage, where the gateway cannot
|
||||||
|
// deliver the settlement header (SSE headers are flushed before usage is known).
|
||||||
|
var modelTokenPrices = map[string]struct{ In, Out float64 }{
|
||||||
|
"gpt-5.6": {5, 30},
|
||||||
|
"gpt-5.6-terra": {2.5, 15},
|
||||||
|
"gpt-5.6-luna": {1, 6},
|
||||||
|
"claude-fable": {10, 50},
|
||||||
|
"claude-opus": {5, 25},
|
||||||
|
"deepseek-v4-flash": {0.14, 0.28},
|
||||||
|
"deepseek-v4-pro": {1.74, 3.48},
|
||||||
|
"deepseek": {0.27, 1.1},
|
||||||
|
"deepseek-reasoner": {0.55, 2.19},
|
||||||
|
"glm-5": {0.6, 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway upto settlement formula constants (see claw402 token_estimate
|
||||||
|
// pricing: token_safety_margin 0.15, token_min_price 0.0001).
|
||||||
|
const (
|
||||||
|
uptoSafetyMargin = 1.15
|
||||||
|
uptoMinPriceUSD = 0.0001
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComputeUsageCost derives the upto-settled cost of a call from token usage,
|
||||||
|
// using the same formula as the claw402 gateway. ok is false for models
|
||||||
|
// without a token price entry.
|
||||||
|
func ComputeUsageCost(model string, promptTokens, completionTokens int) (float64, bool) {
|
||||||
|
p, ok := modelTokenPrices[model]
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
cost := (float64(promptTokens)*p.In + float64(completionTokens)*p.Out) / 1e6 * uptoSafetyMargin
|
||||||
|
if cost < uptoMinPriceUSD {
|
||||||
|
cost = uptoMinPriceUSD
|
||||||
|
}
|
||||||
|
return cost, true
|
||||||
|
}
|
||||||
|
|
||||||
// AIChargeStore handles AI charge records
|
// AIChargeStore handles AI charge records
|
||||||
type AIChargeStore struct {
|
type AIChargeStore struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -62,12 +95,18 @@ func (s *AIChargeStore) initTables() error {
|
|||||||
|
|
||||||
// Record records a new AI charge
|
// Record records a new AI charge
|
||||||
func (s *AIChargeStore) Record(traderID, model, provider string) error {
|
func (s *AIChargeStore) Record(traderID, model, provider string) error {
|
||||||
cost := GetModelPrice(model)
|
return s.RecordWithCost(traderID, model, provider, GetModelPrice(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWithCost records a charge with an explicit cost — e.g. the actual
|
||||||
|
// settled amount reported by the payment gateway (upto scheme) — instead of
|
||||||
|
// the flat per-call estimate from modelPrices.
|
||||||
|
func (s *AIChargeStore) RecordWithCost(traderID, model, provider string, costUSD float64) error {
|
||||||
charge := &AICharge{
|
charge := &AICharge{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
Model: model,
|
Model: model,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
CostUSD: cost,
|
CostUSD: costUSD,
|
||||||
}
|
}
|
||||||
return s.db.Create(charge).Error
|
return s.db.Create(charge).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,27 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
|||||||
return positions, nil
|
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
|
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
||||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||||
var pos TraderPosition
|
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,
|
PriceRankingLimit: 10,
|
||||||
},
|
},
|
||||||
RiskControl: RiskControlConfig{
|
RiskControl: RiskControlConfig{
|
||||||
MaxPositions: 6, // Hold up to 6 instruments (≈3 long + 3 short) simultaneously (CODE ENFORCED)
|
MaxPositions: 2, // Few, concentrated positions held for big moves (CODE ENFORCED)
|
||||||
BTCETHMaxLeverage: 10, // BTC/ETH exchange leverage (AI guided)
|
BTCETHMaxLeverage: 10, // Moderate leverage: a wide (-5%) stop is ~-50% margin, survivable, not an instant liquidation
|
||||||
AltcoinMaxLeverage: 10, // TradeFi exchange leverage (AI guided)
|
AltcoinMaxLeverage: 10, // Moderate leverage: a wide (-5%) stop is ~-50% margin, survivable, not an instant liquidation
|
||||||
BTCETHMaxPositionValueRatio: 1.2, // Per-position notional = equity × 1.2 so several positions fit the margin
|
BTCETHMaxPositionValueRatio: 5.0, // Per-position notional = equity × 5; 2 positions = 10x total (full margin at 10x, ~10% liquidation cushion)
|
||||||
AltcoinMaxPositionValueRatio: 1.2, // Per-position notional = equity × 1.2 so several positions fit the margin
|
AltcoinMaxPositionValueRatio: 5.0, // Per-position notional = equity × 5; 2 positions = 10x total (full margin at 10x, ~10% liquidation cushion)
|
||||||
MaxMarginUsage: 1.0, // Claw402 Autopilot intentionally uses full margin when opening
|
MaxMarginUsage: 1.0, // Claw402 Autopilot intentionally uses full margin when opening
|
||||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||||
|
|||||||
@@ -422,15 +422,11 @@ func (at *AutoTrader) reloadStrategyConfigIfChanged() error {
|
|||||||
}
|
}
|
||||||
strategyConfig.ClampLimits()
|
strategyConfig.ClampLimits()
|
||||||
|
|
||||||
// Autopilot (vergex_signal/claw402) runs a balanced multi-position book:
|
// NOTE: this used to hardcode the Autopilot book shape (6 positions ×
|
||||||
// hold several instruments at once with a smaller per-position notional so
|
// equity×1.2 notional), silently overriding whatever the user configured in
|
||||||
// multiple long/short positions fit the margin. Applied after ClampLimits so
|
// their strategy. Sizing now comes from the strategy's own RiskControl —
|
||||||
// the book size is not capped back down to the conservative default.
|
// ClampLimits above bounds it (ratio 0.5–10, leverage caps), and the
|
||||||
if strategyConfig.CoinSource.SourceType == "vergex_signal" {
|
// margin auto-reduce at order time keeps the book solvent.
|
||||||
strategyConfig.RiskControl.MaxPositions = 6
|
|
||||||
strategyConfig.RiskControl.BTCETHMaxPositionValueRatio = 1.2
|
|
||||||
strategyConfig.RiskControl.AltcoinMaxPositionValueRatio = 1.2
|
|
||||||
}
|
|
||||||
|
|
||||||
claw402Key := at.config.Claw402WalletKey
|
claw402Key := at.config.Claw402WalletKey
|
||||||
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
|
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// forcedCoverageMinScore is the minimum absolute board z-score a candidate
|
// forcedCoverageMinScore is the minimum absolute board z-score a candidate
|
||||||
// needs before the engine will force-open it for book balance. Live trade
|
// needs before the engine will force-open it for book balance. Near-neutral
|
||||||
// history showed forced entries on near-neutral signals (|z| < 0.3) were a
|
// signals (|z| < ~0.3) proved a systematic loser, but a 0.75 floor was too
|
||||||
// systematic money loser — especially shorts — while trades on strong signals
|
// strict: in a long-leaning tape every bearish candidate scored below it, so
|
||||||
// carried the edge. Below this bar the book is simply left unbalanced.
|
// no short ever opened and the book became a one-directional long bet that
|
||||||
const forcedCoverageMinScore = 0.75
|
// 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
|
// ensureLongShortCoverage tops the book up toward roughly half the
|
||||||
// MaxPositions slots long and half short — but only with candidates whose
|
// MaxPositions slots long and half short — but only with candidates whose
|
||||||
|
|||||||
@@ -131,9 +131,28 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record AI charge (track cost regardless of decision outcome)
|
// Record AI charge (track cost regardless of decision outcome).
|
||||||
|
// Use the effective model name (custom model, e.g. "gpt-5.6") so the
|
||||||
|
// per-call price lookup matches what was actually invoked — at.aiModel is
|
||||||
|
// the provider id (e.g. "claw402") and would fall back to the default price.
|
||||||
|
// Prefer the gateway-reported settled amount (upto scheme) over the flat
|
||||||
|
// catalog estimate when the client exposes it.
|
||||||
if aiDecision != nil && at.store != nil {
|
if aiDecision != nil && at.store != nil {
|
||||||
if chargeErr := at.store.AICharge().Record(at.id, at.aiModel, at.config.AIModel); chargeErr != nil {
|
chargeModel := at.config.CustomModelName
|
||||||
|
if chargeModel == "" {
|
||||||
|
chargeModel = at.aiModel
|
||||||
|
}
|
||||||
|
var chargeErr error
|
||||||
|
if r, ok := at.mcpClient.(interface{ LastCallCostUSD() (float64, bool) }); ok {
|
||||||
|
if actual, has := r.LastCallCostUSD(); has {
|
||||||
|
chargeErr = at.store.AICharge().RecordWithCost(at.id, chargeModel, at.config.AIModel, actual)
|
||||||
|
} else {
|
||||||
|
chargeErr = at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chargeErr = at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel)
|
||||||
|
}
|
||||||
|
if chargeErr != nil {
|
||||||
at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr)
|
at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The monitor arms only once the underlying PRICE has moved +5% in the
|
||||||
|
// position's favor (leverage-independent — at 10x the old margin-basis
|
||||||
|
// check armed at a +0.5% price wiggle and strangled every winner), then
|
||||||
|
// closes if the position gives back 40% of its peak profit.
|
||||||
|
drawdownClosePriceGainPct = 5.0
|
||||||
|
drawdownCloseGivebackPct = 40.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// shouldDrawdownClose reports whether the profit-protection close should fire.
|
||||||
|
// pricePnLPct is the price-basis move in the position's favor; drawdownPct is
|
||||||
|
// the relative giveback from the position's peak profit.
|
||||||
|
func shouldDrawdownClose(pricePnLPct, drawdownPct float64) bool {
|
||||||
|
return pricePnLPct > drawdownClosePriceGainPct && drawdownPct >= drawdownCloseGivebackPct
|
||||||
|
}
|
||||||
|
|
||||||
// startDrawdownMonitor starts drawdown monitoring
|
// startDrawdownMonitor starts drawdown monitoring
|
||||||
func (at *AutoTrader) startDrawdownMonitor() {
|
func (at *AutoTrader) startDrawdownMonitor() {
|
||||||
at.monitorWg.Add(1)
|
at.monitorWg.Add(1)
|
||||||
@@ -64,12 +80,17 @@ func (at *AutoTrader) checkPositionDrawdown() {
|
|||||||
leverage = int(lev)
|
leverage = int(lev)
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentPnLPct float64
|
// Price-basis move drives the close decision so the trigger point does
|
||||||
|
// not tighten as leverage grows; the margin-basis (leveraged) value is
|
||||||
|
// only kept for the peak cache shown alongside margin-based PnL% in
|
||||||
|
// prompts.
|
||||||
|
var pricePnLPct float64
|
||||||
if side == "long" {
|
if side == "long" {
|
||||||
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
|
pricePnLPct = ((markPrice - entryPrice) / entryPrice) * 100
|
||||||
} else {
|
} else {
|
||||||
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
|
pricePnLPct = ((entryPrice - markPrice) / entryPrice) * 100
|
||||||
}
|
}
|
||||||
|
currentPnLPct := pricePnLPct * float64(leverage)
|
||||||
|
|
||||||
// Construct unique position identifier (distinguish long/short)
|
// Construct unique position identifier (distinguish long/short)
|
||||||
posKey := symbol + "_" + side
|
posKey := symbol + "_" + side
|
||||||
@@ -94,10 +115,10 @@ func (at *AutoTrader) checkPositionDrawdown() {
|
|||||||
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check close position condition: profit > 5% and drawdown >= 40%
|
// Check close position condition: price move > +5% and drawdown >= 40%
|
||||||
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
|
if shouldDrawdownClose(pricePnLPct, drawdownPct) {
|
||||||
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
|
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Price move: %.2f%% | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
|
||||||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
symbol, side, pricePnLPct, currentPnLPct, peakPnLPct, drawdownPct)
|
||||||
|
|
||||||
// Execute close position
|
// Execute close position
|
||||||
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
||||||
@@ -107,10 +128,10 @@ func (at *AutoTrader) checkPositionDrawdown() {
|
|||||||
// Clear cache for this position after closing
|
// Clear cache for this position after closing
|
||||||
at.ClearPeakPnLCache(symbol, side)
|
at.ClearPeakPnLCache(symbol, side)
|
||||||
}
|
}
|
||||||
} else if currentPnLPct > 5.0 {
|
} else if pricePnLPct > drawdownClosePriceGainPct {
|
||||||
// Record situations close to close position condition (for debugging)
|
// Record situations close to close position condition (for debugging)
|
||||||
logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
|
logger.Infof("📊 Drawdown monitoring: %s %s | Price move: %.2f%% | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
|
||||||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
symbol, side, pricePnLPct, currentPnLPct, peakPnLPct, drawdownPct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
trader/auto_trader_risk_test.go
Normal file
27
trader/auto_trader_risk_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package trader
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDrawdownCloseArmsOnPriceBasisOnly(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pricePnLPct float64
|
||||||
|
drawdownPct float64
|
||||||
|
shouldClose bool
|
||||||
|
}{
|
||||||
|
// +0.5% price move (what +5% margin at 10x used to arm on) must NOT
|
||||||
|
// arm the monitor, no matter how large the relative drawdown is.
|
||||||
|
{"tiny price gain big drawdown", 0.5, 60.0, false},
|
||||||
|
// Armed only from a real +5% price move, and still needs the 40% giveback.
|
||||||
|
{"real gain small drawdown", 6.0, 20.0, false},
|
||||||
|
{"real gain big drawdown", 6.0, 45.0, true},
|
||||||
|
{"at threshold not armed", 5.0, 45.0, false},
|
||||||
|
{"loss never triggers", -3.0, 80.0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := shouldDrawdownClose(c.pricePnLPct, c.drawdownPct); got != c.shouldClose {
|
||||||
|
t.Fatalf("%s: shouldDrawdownClose(%.1f, %.1f) = %v, want %v",
|
||||||
|
c.name, c.pricePnLPct, c.drawdownPct, got, c.shouldClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,25 +10,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Live history: trades held under an hour were net-negative after fees
|
// Anti-churn open caps: at most a couple of new positions per hour/cycle.
|
||||||
// (the 15-60m bucket bled), while the edge concentrated in 1h+ holds.
|
autopilotMaxOpensPerHour = 3
|
||||||
autopilotMinHoldDuration = 60 * time.Minute
|
autopilotMaxOpensPerCycle = 2
|
||||||
autopilotNoiseCloseHoldDuration = 90 * time.Minute
|
|
||||||
autopilotReentryCooldown = 30 * time.Minute
|
// Exit gates, validated by decision replay (2026-07-26, 4154 cycles,
|
||||||
// Allow one long + one short per cycle. The real exposure/churn limits are
|
// 3-fold robustness): gates beat no-gates by 34 pts and the old rigid
|
||||||
// MaxPositions (concurrent) + the 45m min-hold + the 90m per-symbol reentry
|
// 4h/8h by 16 pts of worst-fold score; the searched optimum sits at these
|
||||||
// cooldown, so the per-hour cap only needs to be high enough not to block the
|
// values. Thresholds are PRICE-move percentages (leverage-independent).
|
||||||
// directional pair from re-establishing after positions close. A tight value
|
autopilotMinHoldDuration = 90 * time.Minute
|
||||||
// here (e.g. 2) starves the strategy: once a couple opens fire, every later
|
autopilotNoiseCloseHoldDuration = 3 * time.Hour
|
||||||
// cycle is blocked and the book drains to flat. Keep it generous.
|
// Re-entering a just-closed symbol was a consistent loss source: the
|
||||||
autopilotMaxOpensPerHour = 30
|
// replay's top-20 configs cluster tightly at ~4h.
|
||||||
autopilotMaxOpensPerCycle = 6
|
autopilotReentryCooldown = 4 * time.Hour
|
||||||
earlyCloseStopLossBypassPct = -2.5
|
earlyCloseStopLossBypassPct = -3.0
|
||||||
earlyCloseTakeProfitBypassPct = 5.0
|
earlyCloseTakeProfitBypassPct = 8.0
|
||||||
noiseCloseLossFloorPct = -1.0
|
noiseCloseLossFloorPct = -2.0
|
||||||
noiseCloseProfitCeilingPct = 2.0
|
noiseCloseProfitCeilingPct = 3.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// positionPricePnLPct converts the margin-based UnrealizedPnLPct reported for
|
||||||
|
// a position into the underlying price-move percentage.
|
||||||
|
func positionPricePnLPct(pos *kernel.PositionInfo) float64 {
|
||||||
|
if pos == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pos.Leverage > 1 {
|
||||||
|
return pos.UnrealizedPnLPct / float64(pos.Leverage)
|
||||||
|
}
|
||||||
|
return pos.UnrealizedPnLPct
|
||||||
|
}
|
||||||
|
|
||||||
func isOpenAction(action string) bool {
|
func isOpenAction(action string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||||
case "open_long", "open_short":
|
case "open_long", "open_short":
|
||||||
@@ -132,7 +144,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
|
|||||||
pnlPct := 0.0
|
pnlPct := 0.0
|
||||||
entryTime := int64(0)
|
entryTime := int64(0)
|
||||||
if pos != nil {
|
if pos != nil {
|
||||||
pnlPct = pos.UnrealizedPnLPct
|
pnlPct = positionPricePnLPct(pos)
|
||||||
entryTime = pos.UpdateTime
|
entryTime = pos.UpdateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +168,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
|
|||||||
|
|
||||||
remaining := autopilotNoiseCloseHoldDuration - heldFor
|
remaining := autopilotNoiseCloseHoldDuration - heldFor
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"trade throttle: %s %s has been held for %s with PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close",
|
"trade throttle: %s %s has been held for %s with price PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close",
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
roundDuration(heldFor),
|
roundDuration(heldFor),
|
||||||
@@ -174,7 +186,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
|
|||||||
|
|
||||||
remaining := autopilotMinHoldDuration - heldFor
|
remaining := autopilotMinHoldDuration - heldFor
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"trade throttle: %s %s has only been held for %s with PnL %.2f%%; min AI-managed hold is %s unless loss <= %.1f%% or profit >= %.1f%%",
|
"trade throttle: %s %s has only been held for %s with price PnL %.2f%%; min AI-managed hold is %s unless price loss <= %.1f%% or price profit >= %.1f%%",
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
roundDuration(heldFor),
|
roundDuration(heldFor),
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context {
|
func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context {
|
||||||
|
return leveragedThrottleContext(symbol, side, heldFor, pnlPct, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leveragedThrottleContext(symbol, side string, heldFor time.Duration, pnlPct float64, leverage int) *kernel.Context {
|
||||||
return &kernel.Context{
|
return &kernel.Context{
|
||||||
Positions: []kernel.PositionInfo{
|
Positions: []kernel.PositionInfo{
|
||||||
{
|
{
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
UnrealizedPnLPct: pnlPct,
|
UnrealizedPnLPct: pnlPct,
|
||||||
|
Leverage: leverage,
|
||||||
UpdateTime: time.Now().Add(-heldFor).UnixMilli(),
|
UpdateTime: time.Now().Add(-heldFor).UnixMilli(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -32,7 +37,8 @@ func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
|
|||||||
|
|
||||||
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
||||||
at := &AutoTrader{}
|
at := &AutoTrader{}
|
||||||
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -3.0)
|
// A price loss beyond the default -3% bypass unlocks the min hold.
|
||||||
|
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -6.0)
|
||||||
|
|
||||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||||
if reason != "" {
|
if reason != "" {
|
||||||
@@ -40,9 +46,42 @@ func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTradeThrottleBypassIsPriceBasisNotMarginBasis(t *testing.T) {
|
||||||
|
at := &AutoTrader{}
|
||||||
|
// At 10x leverage the exchange reports margin-based PnL: -6% margin is
|
||||||
|
// only a -0.6% price move — noise, must NOT bypass the min hold.
|
||||||
|
ctx := leveragedThrottleContext("xyz:INTC", "long", 20*time.Minute, -6.0, 10)
|
||||||
|
|
||||||
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||||
|
if !strings.Contains(reason, "min AI-managed hold") {
|
||||||
|
t.Fatalf("expected -0.6%% price move to stay blocked at 10x, got %q", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -60% margin at 10x is a real -6% price move — bypass allowed.
|
||||||
|
ctx = leveragedThrottleContext("xyz:INTC", "long", 20*time.Minute, -60.0, 10)
|
||||||
|
reason = at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||||
|
if reason != "" {
|
||||||
|
t.Fatalf("expected -6%% price move to bypass min hold at 10x, got %q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTradeThrottleNoiseBandIsPriceBasisNotMarginBasis(t *testing.T) {
|
||||||
|
at := &AutoTrader{}
|
||||||
|
// Past min hold at 10x: +20% margin is only a +2% price move, still
|
||||||
|
// inside the default -2%..+3% noise band — flat close must stay blocked.
|
||||||
|
ctx := leveragedThrottleContext("xyz:INTC", "long", 2*time.Hour, 20.0, 10)
|
||||||
|
|
||||||
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||||
|
if !strings.Contains(reason, "noise band") {
|
||||||
|
t.Fatalf("expected +2%% price move to be blocked inside noise band at 10x, got %q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
||||||
at := &AutoTrader{}
|
at := &AutoTrader{}
|
||||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, 0.4)
|
// Held past the default 90m min hold but still inside the noise band and
|
||||||
|
// under the 3h noise window.
|
||||||
|
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, 0.4)
|
||||||
|
|
||||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||||
if !strings.Contains(reason, "noise band") {
|
if !strings.Contains(reason, "noise band") {
|
||||||
@@ -52,7 +91,8 @@ func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
|||||||
|
|
||||||
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
||||||
at := &AutoTrader{}
|
at := &AutoTrader{}
|
||||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, -1.2)
|
// Past the min hold, loss beyond the -2% noise floor → close allowed.
|
||||||
|
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, -2.5)
|
||||||
|
|
||||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||||
if reason != "" {
|
if reason != "" {
|
||||||
@@ -60,6 +100,18 @@ func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTradeThrottleBlocksQuickReentryAfterClose(t *testing.T) {
|
||||||
|
// Re-entering a just-closed symbol was a consistent loss source in the
|
||||||
|
// replay data; the 4h cooldown is enforced from recent close orders, which
|
||||||
|
// requires a store — covered by the throttle reason path being non-empty
|
||||||
|
// only when a recent close order exists (nil store returns no orders).
|
||||||
|
at := &AutoTrader{}
|
||||||
|
ctx := &kernel.Context{}
|
||||||
|
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 0); reason != "" {
|
||||||
|
t.Fatalf("expected open with no order history to be allowed, got %q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTradeThrottleAllowsLongShortPairInCycle(t *testing.T) {
|
func TestTradeThrottleAllowsLongShortPairInCycle(t *testing.T) {
|
||||||
at := &AutoTrader{}
|
at := &AutoTrader{}
|
||||||
ctx := &kernel.Context{}
|
ctx := &kernel.Context{}
|
||||||
@@ -76,13 +128,13 @@ func TestTradeThrottleBlocksOpensOverCycleCap(t *testing.T) {
|
|||||||
at := &AutoTrader{}
|
at := &AutoTrader{}
|
||||||
ctx := &kernel.Context{}
|
ctx := &kernel.Context{}
|
||||||
|
|
||||||
// under the 6-per-cycle cap, a further open is allowed
|
// under the 2-per-cycle cap, a further open is allowed
|
||||||
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 5); reason != "" {
|
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1); reason != "" {
|
||||||
t.Fatalf("expected open within the 6-per-cycle cap to be allowed, got %q", 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
|
// 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") {
|
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 6-per-cycle cap to be blocked, got %q", reason)
|
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")
|
return fmt.Errorf("store is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recent trades (last 24 hours)
|
// Look back 7 days. GetTrades now pulls up to 2000 recent fills (UserFills)
|
||||||
startTime := time.Now().Add(-24 * time.Hour)
|
// 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))
|
logger.Infof("🔄 Syncing Hyperliquid trades from: %s", startTime.Format(time.RFC3339))
|
||||||
|
|
||||||
// Use GetTrades method to fetch trade records
|
// Use GetTrades method to fetch trade records
|
||||||
trades, err := t.GetTrades(startTime, 1000)
|
trades, err := t.GetTrades(startTime, 2000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get trades: %w", err)
|
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)
|
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
|
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
|
// 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{}) {
|
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 {
|
syncloop.Run(stop, interval, "Hyperliquid", func() error {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
hl "github.com/sonirico/go-hyperliquid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetBalance gets account balance
|
// GetBalance gets account balance
|
||||||
@@ -548,15 +550,23 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]type
|
|||||||
|
|
||||||
// GetTrades retrieves trade history from Hyperliquid
|
// GetTrades retrieves trade history from Hyperliquid
|
||||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
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()
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trades []types.TradeRecord
|
var trades []types.TradeRecord
|
||||||
for _, fill := range fills {
|
for _, fill := range fills {
|
||||||
|
if fill.Time < startTimeMs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
price, _ := strconv.ParseFloat(fill.Price, 64)
|
price, _ := strconv.ParseFloat(fill.Price, 64)
|
||||||
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
||||||
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
||||||
|
|||||||
64
web/package-lock.json
generated
64
web/package-lock.json
generated
@@ -1010,9 +1010,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1084,9 +1084,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3073,9 +3073,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.16.1",
|
"version": "1.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
@@ -3140,9 +3140,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.3",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4354,9 +4354,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4418,9 +4418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6236,9 +6236,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6631,9 +6631,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6651,7 +6651,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -7049,9 +7049,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.17.0",
|
"version": "7.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||||
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -7071,12 +7071,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.17.0",
|
"version": "7.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||||
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.17.0"
|
"react-router": "7.18.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -69,5 +69,9 @@
|
|||||||
"*.{css,json}": [
|
"*.{css,json}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"brace-expansion@1": "~1.1.16",
|
||||||
|
"brace-expansion@2": "~2.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useAuth } from '../../contexts/AuthContext'
|
|||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
@@ -64,7 +63,6 @@ export function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DeepVoidBackground disableAnimation>
|
<DeepVoidBackground disableAnimation>
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
{/* Self-contained centering grid — works regardless of parent flex setup */}
|
{/* Self-contained centering grid — works regardless of parent flex setup */}
|
||||||
<main className="flex-1 grid lg:grid-cols-2">
|
<main className="flex-1 grid lg:grid-cols-2">
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export default function HeaderBar({
|
|||||||
isLoggedIn = false,
|
isLoggedIn = false,
|
||||||
isHomePage = false,
|
isHomePage = false,
|
||||||
currentPage,
|
currentPage,
|
||||||
language = 'zh' as Language,
|
language = 'en' as Language,
|
||||||
onLanguageChange,
|
|
||||||
user,
|
user,
|
||||||
onLogout,
|
onLogout,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@@ -40,12 +39,10 @@ export default function HeaderBar({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
|
||||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||||
const [userMode, setUserModeState] = useState<UserMode>(
|
const [userMode, setUserModeState] = useState<UserMode>(
|
||||||
() => getUserMode() ?? 'advanced'
|
() => getUserMode() ?? 'advanced'
|
||||||
)
|
)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const resolvedCurrentPage =
|
const resolvedCurrentPage =
|
||||||
currentPage ?? getCurrentPageForPath(location.pathname)
|
currentPage ?? getCurrentPageForPath(location.pathname)
|
||||||
@@ -63,12 +60,6 @@ export default function HeaderBar({
|
|||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setLanguageDropdownOpen(false)
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
userDropdownRef.current &&
|
userDropdownRef.current &&
|
||||||
!userDropdownRef.current.contains(event.target as Node)
|
!userDropdownRef.current.contains(event.target as Node)
|
||||||
@@ -357,56 +348,7 @@ export default function HeaderBar({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Language Toggle - Always at the rightmost */}
|
{/* Language switcher removed — the product UI is English-only. */}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -619,28 +561,8 @@ export default function HeaderBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account / Lang */}
|
{/* Account (language switcher removed — English-only UI) */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 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>
|
|
||||||
|
|
||||||
{/* Auth Actions */}
|
{/* Auth Actions */}
|
||||||
{isLoggedIn && user ? (
|
{isLoggedIn && user ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
552
web/src/components/common/HyperliquidFundsPanel.tsx
Normal file
552
web/src/components/common/HyperliquidFundsPanel.tsx
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ArrowDownUp, Loader2, QrCode, RefreshCw } from 'lucide-react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
import type { HyperliquidAccountSummary } from '../../lib/api/wallet'
|
||||||
|
import type { Language } from '../../i18n/translations'
|
||||||
|
import { copyWithToast } from '../../lib/clipboard'
|
||||||
|
import {
|
||||||
|
formatUSDC,
|
||||||
|
getPreferredWalletProvider,
|
||||||
|
normalizeAddress,
|
||||||
|
shortAddress,
|
||||||
|
signHyperliquidUserAction,
|
||||||
|
} from '../../lib/hyperliquidWallet'
|
||||||
|
|
||||||
|
// Hyperliquid only credits one canonical deposit route: NATIVE USDC on
|
||||||
|
// Arbitrum One sent to the validator-controlled Bridge2 contract. The sender
|
||||||
|
// address is the account that gets credited (min 5 USDC, ~1 minute).
|
||||||
|
const ARBITRUM_CHAIN_ID = '0xa4b1' // 42161
|
||||||
|
const ARBITRUM_NATIVE_USDC = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
|
||||||
|
const HYPERLIQUID_BRIDGE2 = '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7'
|
||||||
|
const MIN_BRIDGE_DEPOSIT_USDC = 5
|
||||||
|
const ARBITRUM_RPC = 'https://arb1.arbitrum.io/rpc'
|
||||||
|
|
||||||
|
function erc20TransferData(to: string, amountUnits: bigint) {
|
||||||
|
const addr = to.toLowerCase().replace(/^0x/, '').padStart(64, '0')
|
||||||
|
const amount = amountUnits.toString(16).padStart(64, '0')
|
||||||
|
return `0xa9059cbb${addr}${amount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function arbitrumRpc(method: string, params: unknown[]): Promise<string> {
|
||||||
|
const res = await fetch(ARBITRUM_RPC, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||||
|
})
|
||||||
|
const data = (await res.json()) as { result?: unknown }
|
||||||
|
return typeof data.result === 'string' ? data.result : '0x0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain Arbitrum balances of the main wallet — the funds a bridge deposit can draw from. */
|
||||||
|
async function fetchArbitrumBalances(address: string) {
|
||||||
|
const padded = address.replace(/^0x/, '').padStart(64, '0')
|
||||||
|
const [usdcHex, ethHex] = await Promise.all([
|
||||||
|
arbitrumRpc('eth_call', [
|
||||||
|
{ to: ARBITRUM_NATIVE_USDC, data: `0x70a08231${padded}` },
|
||||||
|
'latest',
|
||||||
|
]),
|
||||||
|
arbitrumRpc('eth_getBalance', [address, 'latest']),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
usdc: Number(BigInt(usdcHex)) / 1e6,
|
||||||
|
eth: Number(BigInt(ethHex)) / 1e18,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HyperliquidFundsPanelProps {
|
||||||
|
language: Language
|
||||||
|
walletAddress: string
|
||||||
|
/**
|
||||||
|
* Hyperliquid "unified account" mode (the default, and HL's recommendation):
|
||||||
|
* one USDC balance collateralizes spot, validator perps and HIP-3 perps, so
|
||||||
|
* there is no spot/perp split and no class transfer — the transfer tab is
|
||||||
|
* hidden and a single account card is shown. Manual/standard-mode accounts
|
||||||
|
* keep the split view with the spot<->perp transfer.
|
||||||
|
*/
|
||||||
|
unifiedAccount?: boolean
|
||||||
|
onTransferred?: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
zh: {
|
||||||
|
deposit: '充值',
|
||||||
|
transfer: '划转',
|
||||||
|
balances: '余额',
|
||||||
|
spot: '现货(主钱包)',
|
||||||
|
perp: '合约(交易账户)',
|
||||||
|
available: '可用',
|
||||||
|
withdrawable: '可提取',
|
||||||
|
depositHint:
|
||||||
|
'二维码是你的主钱包地址。第一步:通过 Arbitrum One 链把 USDC 转到这个地址(进钱包)。第二步:用下方按钮存入 Hyperliquid,约 1 分钟到账,到账后即可直接用于交易。',
|
||||||
|
hlAccount: 'Hyperliquid 账户',
|
||||||
|
total: '总额',
|
||||||
|
tradable: '可交易',
|
||||||
|
marginInUse: '保证金占用',
|
||||||
|
depositWarn:
|
||||||
|
'入金只认 Arbitrum One 上的原生 USDC。USDT 或其他链的资产需先兑换成 Arbitrum USDC。',
|
||||||
|
bridgeTitle: '存入 Hyperliquid(钱包 → 交易账户)',
|
||||||
|
bridgeAmount: '入金数量 (USDC)',
|
||||||
|
bridgeSubmit: '存入交易账户',
|
||||||
|
bridgeSubmitting: '存入中…',
|
||||||
|
bridgeMin: `最低入金 ${MIN_BRIDGE_DEPOSIT_USDC} USDC,低于此额度会丢失`,
|
||||||
|
bridgeGasHint: '需要钱包里有少量 Arbitrum ETH 作为 gas。',
|
||||||
|
bridgeSubmitted: '入金交易已提交,约 1 分钟后到账合约账户',
|
||||||
|
wallet: '钱包 (Arbitrum)',
|
||||||
|
depositable: '可入金',
|
||||||
|
gas: 'Gas',
|
||||||
|
noGas: 'Arbitrum ETH 为 0,无法支付 gas',
|
||||||
|
copyAddress: '复制地址',
|
||||||
|
addressCopied: '地址已复制',
|
||||||
|
spotToPerp: '现货 → 合约',
|
||||||
|
perpToSpot: '合约 → 现货',
|
||||||
|
amount: '划转数量 (USDC)',
|
||||||
|
max: '全部',
|
||||||
|
submit: '签名并划转',
|
||||||
|
submitting: '划转中…',
|
||||||
|
connectFirst: '连接主钱包以签名划转',
|
||||||
|
connect: '连接钱包',
|
||||||
|
noProvider: '未检测到浏览器钱包插件(如 MetaMask / OKX)',
|
||||||
|
wrongWallet: (want: string, got: string) =>
|
||||||
|
`钱包地址不匹配:需要 ${want},当前连接 ${got}。划转签名必须来自主钱包本身。`,
|
||||||
|
invalidAmount: '请输入有效的划转数量',
|
||||||
|
exceedsBalance: '超出可用余额',
|
||||||
|
success: '划转成功,余额稍后刷新',
|
||||||
|
failed: '划转失败',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
deposit: 'Deposit',
|
||||||
|
transfer: 'Transfer',
|
||||||
|
balances: 'Balances',
|
||||||
|
spot: 'Spot (main wallet)',
|
||||||
|
perp: 'Perp (trading account)',
|
||||||
|
available: 'available',
|
||||||
|
withdrawable: 'withdrawable',
|
||||||
|
depositHint:
|
||||||
|
'The QR is your main wallet address. Step 1: send USDC to it on Arbitrum One (funds the wallet). Step 2: deposit into Hyperliquid with the button below — credited in about a minute and immediately tradable.',
|
||||||
|
hlAccount: 'Hyperliquid account',
|
||||||
|
total: 'Total',
|
||||||
|
tradable: 'Tradable',
|
||||||
|
marginInUse: 'Margin in use',
|
||||||
|
depositWarn:
|
||||||
|
'Deposits only accept native USDC on Arbitrum One. Swap USDT or assets on other chains into Arbitrum USDC first.',
|
||||||
|
bridgeTitle: 'Deposit to Hyperliquid (wallet → trading account)',
|
||||||
|
bridgeAmount: 'Deposit amount (USDC)',
|
||||||
|
bridgeSubmit: 'Deposit to trading account',
|
||||||
|
bridgeSubmitting: 'Depositing…',
|
||||||
|
bridgeMin: `Minimum deposit ${MIN_BRIDGE_DEPOSIT_USDC} USDC — smaller amounts are lost`,
|
||||||
|
bridgeGasHint: 'Requires a little Arbitrum ETH in the wallet for gas.',
|
||||||
|
bridgeSubmitted: 'Deposit submitted, credited to the perp account in ~1 minute',
|
||||||
|
wallet: 'Wallet (Arbitrum)',
|
||||||
|
depositable: 'depositable',
|
||||||
|
gas: 'Gas',
|
||||||
|
noGas: 'No Arbitrum ETH for gas',
|
||||||
|
copyAddress: 'Copy address',
|
||||||
|
addressCopied: 'Address copied',
|
||||||
|
spotToPerp: 'Spot → Perp',
|
||||||
|
perpToSpot: 'Perp → Spot',
|
||||||
|
amount: 'Amount (USDC)',
|
||||||
|
max: 'Max',
|
||||||
|
submit: 'Sign & transfer',
|
||||||
|
submitting: 'Transferring…',
|
||||||
|
connectFirst: 'Connect the main wallet to sign the transfer',
|
||||||
|
connect: 'Connect wallet',
|
||||||
|
noProvider: 'No browser wallet extension detected (MetaMask / OKX etc.)',
|
||||||
|
wrongWallet: (want: string, got: string) =>
|
||||||
|
`Wallet mismatch: expected ${want} but connected ${got}. The transfer must be signed by the main wallet itself.`,
|
||||||
|
invalidAmount: 'Enter a valid transfer amount',
|
||||||
|
exceedsBalance: 'Amount exceeds the available balance',
|
||||||
|
success: 'Transfer submitted, balances refresh shortly',
|
||||||
|
failed: 'Transfer failed',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HyperliquidFundsPanel({
|
||||||
|
language,
|
||||||
|
walletAddress,
|
||||||
|
unifiedAccount = true,
|
||||||
|
onTransferred,
|
||||||
|
}: HyperliquidFundsPanelProps) {
|
||||||
|
const t = TEXT[language === 'zh' ? 'zh' : 'en']
|
||||||
|
const address = normalizeAddress(walletAddress)
|
||||||
|
const [tab, setTab] = useState<'deposit' | 'transfer'>('deposit')
|
||||||
|
const [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [toPerp, setToPerp] = useState(true)
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [depositAmount, setDepositAmount] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [walletUsdc, setWalletUsdc] = useState<number | undefined>()
|
||||||
|
const [walletEth, setWalletEth] = useState<number | undefined>()
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!address) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [summary, chain] = await Promise.allSettled([
|
||||||
|
api.getHyperliquidAccount(address),
|
||||||
|
fetchArbitrumBalances(address),
|
||||||
|
])
|
||||||
|
if (summary.status === 'fulfilled') setAccount(summary.value)
|
||||||
|
if (chain.status === 'fulfilled') {
|
||||||
|
setWalletUsdc(chain.value.usdc)
|
||||||
|
setWalletEth(chain.value.eth)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [address])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const availableFrom = toPerp
|
||||||
|
? (account?.spotUsdcAvailable ?? 0)
|
||||||
|
: (account?.withdrawable ?? 0)
|
||||||
|
|
||||||
|
async function submitTransfer() {
|
||||||
|
setError('')
|
||||||
|
const parsed = Number(amount)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
setError(t.invalidAmount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parsed > availableFrom + 1e-9) {
|
||||||
|
setError(t.exceedsBalance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const provider = getPreferredWalletProvider()
|
||||||
|
if (!provider) {
|
||||||
|
setError(t.noProvider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const accounts = (await provider.request({
|
||||||
|
method: 'eth_requestAccounts',
|
||||||
|
})) as string[]
|
||||||
|
const signer = normalizeAddress(accounts?.[0] ?? '')
|
||||||
|
if (!signer) throw new Error(t.connectFirst)
|
||||||
|
if (signer !== address) {
|
||||||
|
throw new Error(t.wrongWallet(shortAddress(address), shortAddress(signer)))
|
||||||
|
}
|
||||||
|
const nonce = Date.now()
|
||||||
|
const action = {
|
||||||
|
type: 'usdClassTransfer',
|
||||||
|
signatureChainId: '0x66eee',
|
||||||
|
hyperliquidChain: 'Mainnet',
|
||||||
|
amount: String(parsed),
|
||||||
|
toPerp,
|
||||||
|
nonce,
|
||||||
|
}
|
||||||
|
const signature = await signHyperliquidUserAction(
|
||||||
|
provider,
|
||||||
|
signer,
|
||||||
|
action,
|
||||||
|
'HyperliquidTransaction:UsdClassTransfer',
|
||||||
|
[
|
||||||
|
{ name: 'hyperliquidChain', type: 'string' },
|
||||||
|
{ name: 'amount', type: 'string' },
|
||||||
|
{ name: 'toPerp', type: 'bool' },
|
||||||
|
{ name: 'nonce', type: 'uint64' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await api.submitHyperliquidApproval(action, nonce, signature)
|
||||||
|
toast.success(t.success)
|
||||||
|
setAmount('')
|
||||||
|
await onTransferred?.()
|
||||||
|
// Hyperliquid settles the class transfer near-instantly; one refresh
|
||||||
|
// shortly after covers indexing lag.
|
||||||
|
setTimeout(() => void refresh(), 1500)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t.failed)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBridgeDeposit() {
|
||||||
|
setError('')
|
||||||
|
const parsed = Number(depositAmount)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
setError(t.invalidAmount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parsed < MIN_BRIDGE_DEPOSIT_USDC) {
|
||||||
|
setError(t.bridgeMin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (walletUsdc !== undefined && parsed > walletUsdc + 1e-9) {
|
||||||
|
setError(t.exceedsBalance)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const provider = getPreferredWalletProvider()
|
||||||
|
if (!provider) {
|
||||||
|
setError(t.noProvider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const accounts = (await provider.request({
|
||||||
|
method: 'eth_requestAccounts',
|
||||||
|
})) as string[]
|
||||||
|
const signer = normalizeAddress(accounts?.[0] ?? '')
|
||||||
|
if (!signer) throw new Error(t.connectFirst)
|
||||||
|
// The bridge credits the SENDER: sending from any other wallet would
|
||||||
|
// fund that wallet's Hyperliquid account instead of this one.
|
||||||
|
if (signer !== address) {
|
||||||
|
throw new Error(t.wrongWallet(shortAddress(address), shortAddress(signer)))
|
||||||
|
}
|
||||||
|
await provider.request({
|
||||||
|
method: 'wallet_switchEthereumChain',
|
||||||
|
params: [{ chainId: ARBITRUM_CHAIN_ID }],
|
||||||
|
})
|
||||||
|
const units = BigInt(Math.round(parsed * 1e6))
|
||||||
|
await provider.request({
|
||||||
|
method: 'eth_sendTransaction',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
from: signer,
|
||||||
|
to: ARBITRUM_NATIVE_USDC,
|
||||||
|
data: erc20TransferData(HYPERLIQUID_BRIDGE2, units),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
toast.success(t.bridgeSubmitted)
|
||||||
|
setDepositAmount('')
|
||||||
|
setTimeout(() => void refresh(), 60_000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t.failed)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('deposit')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-sm font-semibold flex items-center gap-1.5 ${
|
||||||
|
tab === 'deposit'
|
||||||
|
? 'bg-nofx-gold text-white'
|
||||||
|
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<QrCode size={14} />
|
||||||
|
{t.deposit}
|
||||||
|
</button>
|
||||||
|
{!unifiedAccount && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('transfer')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-sm font-semibold flex items-center gap-1.5 ${
|
||||||
|
tab === 'transfer'
|
||||||
|
? 'bg-nofx-gold text-white'
|
||||||
|
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowDownUp size={14} />
|
||||||
|
{t.transfer}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
className="text-nofx-text-muted hover:text-nofx-text"
|
||||||
|
title={t.balances}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div className="rounded-xl border border-nofx-gold/30 bg-nofx-gold/5 p-3">
|
||||||
|
<div className="text-nofx-text-muted text-xs">{t.wallet}</div>
|
||||||
|
<div className="font-mono font-medium text-nofx-text">
|
||||||
|
{formatUSDC(walletUsdc)} USDC
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-nofx-text-muted">
|
||||||
|
{walletEth !== undefined && walletEth <= 0 ? (
|
||||||
|
<span className="text-red-500">{t.noGas}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t.gas}: {walletEth === undefined ? '--' : walletEth.toFixed(4)}{' '}
|
||||||
|
ETH
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{unifiedAccount ? (
|
||||||
|
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg p-3 col-span-2">
|
||||||
|
<div className="text-nofx-text-muted text-xs">{t.hlAccount}</div>
|
||||||
|
<div className="font-mono font-medium text-nofx-text">
|
||||||
|
{formatUSDC(account?.spotUsdc)} USDC
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-nofx-text-muted">
|
||||||
|
{t.tradable}: {formatUSDC(account?.spotUsdcAvailable)} ·{' '}
|
||||||
|
{t.marginInUse}:{' '}
|
||||||
|
{account
|
||||||
|
? formatUSDC(account.spotUsdc - account.spotUsdcAvailable)
|
||||||
|
: '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg p-3">
|
||||||
|
<div className="text-nofx-text-muted text-xs">{t.spot}</div>
|
||||||
|
<div className="font-mono font-medium text-nofx-text">
|
||||||
|
{formatUSDC(account?.spotUsdc)} USDC
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-nofx-text-muted">
|
||||||
|
{t.available}: {formatUSDC(account?.spotUsdcAvailable)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg p-3">
|
||||||
|
<div className="text-nofx-text-muted text-xs">{t.perp}</div>
|
||||||
|
<div className="font-mono font-medium text-nofx-text">
|
||||||
|
{formatUSDC(account?.accountValue)} USDC
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-nofx-text-muted">
|
||||||
|
{t.withdrawable}: {formatUSDC(account?.withdrawable)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'deposit' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-center rounded-xl bg-white p-4">
|
||||||
|
<QRCodeSVG value={walletAddress} size={168} marginSize={1} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyWithToast(walletAddress, t.addressCopied)}
|
||||||
|
className="w-full font-mono text-xs text-center break-all text-nofx-text-muted hover:text-nofx-gold"
|
||||||
|
title={t.copyAddress}
|
||||||
|
>
|
||||||
|
{walletAddress}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-nofx-text-muted leading-5">{t.depositHint}</p>
|
||||||
|
<p className="text-xs text-amber-500">{t.depositWarn}</p>
|
||||||
|
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-nofx-text">
|
||||||
|
{t.bridgeTitle}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-nofx-text-muted">
|
||||||
|
{t.depositable}: {formatUSDC(walletUsdc)} USDC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={MIN_BRIDGE_DEPOSIT_USDC}
|
||||||
|
step="0.01"
|
||||||
|
value={depositAmount}
|
||||||
|
onChange={(e) => setDepositAmount(e.target.value)}
|
||||||
|
className="flex-1 rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper px-3 py-1.5 text-sm font-mono text-nofx-text"
|
||||||
|
placeholder={t.bridgeAmount}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
walletUsdc !== undefined &&
|
||||||
|
setDepositAmount((Math.floor(walletUsdc * 100) / 100).toString())
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper text-sm text-nofx-text-muted hover:text-nofx-text"
|
||||||
|
>
|
||||||
|
{t.max}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submitBridgeDeposit()}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-nofx-gold/30 bg-nofx-gold/10 px-4 py-1.5 text-sm font-bold text-nofx-gold transition hover:bg-nofx-gold/20 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{busy ? t.bridgeSubmitting : t.bridgeSubmit}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
<p className="text-xs text-nofx-text-muted">
|
||||||
|
{t.bridgeMin} · {t.bridgeGasHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setToPerp(true)}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded-xl text-sm font-semibold ${
|
||||||
|
toPerp
|
||||||
|
? 'bg-nofx-gold text-white'
|
||||||
|
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.spotToPerp}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setToPerp(false)}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded-xl text-sm font-semibold ${
|
||||||
|
!toPerp
|
||||||
|
? 'bg-nofx-gold text-white'
|
||||||
|
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.perpToSpot}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-nofx-text-muted">{t.amount}</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="flex-1 rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg px-3 py-1.5 text-sm font-mono text-nofx-text"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAmount(availableFrom.toFixed(2))}
|
||||||
|
className="px-3 py-1.5 rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg text-sm text-nofx-text-muted hover:text-nofx-text"
|
||||||
|
>
|
||||||
|
{t.max}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void submitTransfer()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 rounded-xl border border-nofx-gold/30 bg-nofx-gold/10 px-4 py-2.5 text-sm font-bold text-nofx-gold transition hover:bg-nofx-gold/20 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{busy ? t.submitting : t.submit}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-nofx-text-muted">{t.connectFirst}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,31 +18,14 @@ import type {
|
|||||||
HyperliquidAgentInfo,
|
HyperliquidAgentInfo,
|
||||||
} from '../../lib/api/wallet'
|
} from '../../lib/api/wallet'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
|
import {
|
||||||
declare global {
|
buildTypedData,
|
||||||
interface Window {
|
formatUSDC,
|
||||||
ethereum?: WalletProvider & { providers?: WalletProvider[] }
|
getPreferredWalletProvider,
|
||||||
}
|
normalizeAddress,
|
||||||
}
|
shortAddress,
|
||||||
|
splitSignature,
|
||||||
type WalletProvider = {
|
} from '../../lib/hyperliquidWallet'
|
||||||
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
|
|
||||||
on?: (event: string, handler: (...args: unknown[]) => void) => void
|
|
||||||
removeListener?: (
|
|
||||||
event: string,
|
|
||||||
handler: (...args: unknown[]) => void
|
|
||||||
) => void
|
|
||||||
isMetaMask?: boolean
|
|
||||||
isRabby?: boolean
|
|
||||||
isOkxWallet?: boolean
|
|
||||||
isCoinbaseWallet?: boolean
|
|
||||||
isTrust?: boolean
|
|
||||||
isPhantom?: boolean
|
|
||||||
isBackpack?: boolean
|
|
||||||
isBraveWallet?: boolean
|
|
||||||
isExodus?: boolean
|
|
||||||
isFrame?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type StepStatus = 'pending' | 'active' | 'done' | 'error'
|
type StepStatus = 'pending' | 'active' | 'done' | 'error'
|
||||||
|
|
||||||
@@ -81,11 +64,6 @@ const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d'
|
|||||||
// this exact string when approving the builder during wallet connect.
|
// this exact string when approving the builder during wallet connect.
|
||||||
const HYPERLIQUID_BUILDER_MAX_FEE = '0.05%'
|
const HYPERLIQUID_BUILDER_MAX_FEE = '0.05%'
|
||||||
|
|
||||||
function shortAddress(address?: string) {
|
|
||||||
if (!address) return ''
|
|
||||||
return `${address.slice(0, 6)}…${address.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function copy(text: string, label: string) {
|
function copy(text: string, label: string) {
|
||||||
navigator.clipboard?.writeText(text).then(
|
navigator.clipboard?.writeText(text).then(
|
||||||
() => toast.success(`${label} copied`),
|
() => toast.success(`${label} copied`),
|
||||||
@@ -93,42 +71,6 @@ function copy(text: string, label: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAddress(address: string) {
|
|
||||||
return address.trim().toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWalletProviders(): WalletProvider[] {
|
|
||||||
const injected = window.ethereum
|
|
||||||
if (!injected) return []
|
|
||||||
const providers =
|
|
||||||
Array.isArray(injected.providers) && injected.providers.length > 0
|
|
||||||
? injected.providers
|
|
||||||
: [injected]
|
|
||||||
const seen = new Set<WalletProvider>()
|
|
||||||
return providers.filter((provider) => {
|
|
||||||
if (!provider || seen.has(provider)) return false
|
|
||||||
seen.add(provider)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPreferredWalletProvider(): WalletProvider | undefined {
|
|
||||||
const providers = getWalletProviders()
|
|
||||||
return (
|
|
||||||
providers.find((provider) => provider.isRabby) ||
|
|
||||||
providers.find((provider) => provider.isMetaMask) ||
|
|
||||||
providers.find((provider) => provider.isCoinbaseWallet) ||
|
|
||||||
providers.find((provider) => provider.isPhantom) ||
|
|
||||||
providers.find((provider) => provider.isBraveWallet) ||
|
|
||||||
providers.find((provider) => provider.isBackpack) ||
|
|
||||||
providers.find((provider) => provider.isOkxWallet) ||
|
|
||||||
providers.find((provider) => provider.isTrust) ||
|
|
||||||
providers.find((provider) => provider.isExodus) ||
|
|
||||||
providers.find((provider) => provider.isFrame) ||
|
|
||||||
providers[0]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function walletSupportLabel(language: Language) {
|
function walletSupportLabel(language: Language) {
|
||||||
return language === 'zh'
|
return language === 'zh'
|
||||||
? 'Supports MetaMask, Rabby, Coinbase, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
|
? 'Supports MetaMask, Rabby, Coinbase, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
|
||||||
@@ -150,59 +92,12 @@ function formatAgentExpiry(validUntil: number, language: Language) {
|
|||||||
return { dateStr, daysLeft }
|
return { dateStr, daysLeft }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUSDC(value?: number) {
|
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedUSDC(value?: number) {
|
function formatSignedUSDC(value?: number) {
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
|
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
|
||||||
const sign = value > 0 ? '+' : ''
|
const sign = value > 0 ? '+' : ''
|
||||||
return `${sign}${formatUSDC(value)}`
|
return `${sign}${formatUSDC(value)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitSignature(signature: string) {
|
|
||||||
const hex = signature.startsWith('0x') ? signature.slice(2) : signature
|
|
||||||
if (hex.length !== 130) {
|
|
||||||
throw new Error('Invalid wallet signature length')
|
|
||||||
}
|
|
||||||
const v = parseInt(hex.slice(128, 130), 16)
|
|
||||||
return {
|
|
||||||
r: `0x${hex.slice(0, 64)}`,
|
|
||||||
s: `0x${hex.slice(64, 128)}`,
|
|
||||||
v: v < 27 ? v + 27 : v,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTypedData(
|
|
||||||
primaryType: string,
|
|
||||||
fields: { name: string; type: string }[],
|
|
||||||
message: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
domain: {
|
|
||||||
name: 'HyperliquidSignTransaction',
|
|
||||||
version: '1',
|
|
||||||
chainId: 421614,
|
|
||||||
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
||||||
},
|
|
||||||
types: {
|
|
||||||
EIP712Domain: [
|
|
||||||
{ name: 'name', type: 'string' },
|
|
||||||
{ name: 'version', type: 'string' },
|
|
||||||
{ name: 'chainId', type: 'uint256' },
|
|
||||||
{ name: 'verifyingContract', type: 'address' },
|
|
||||||
],
|
|
||||||
[primaryType]: fields,
|
|
||||||
},
|
|
||||||
primaryType,
|
|
||||||
message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSavedState(): FlowState {
|
function getSavedState(): FlowState {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const MODEL_COLORS: Record<string, string> = {
|
|||||||
openai: '#10A37F',
|
openai: '#10A37F',
|
||||||
minimax: '#E45735',
|
minimax: '#E45735',
|
||||||
claw402: '#7C3AED',
|
claw402: '#7C3AED',
|
||||||
|
zhipu: '#3859F3',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the icon for an AI model
|
// Returns the icon for an AI model
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { invalidateSystemConfig } from '../../lib/config'
|
|||||||
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
||||||
import type { UserMode } from '../../lib/onboarding'
|
import type { UserMode } from '../../lib/onboarding'
|
||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
zh: {
|
zh: {
|
||||||
@@ -127,7 +126,6 @@ export function SetupPage() {
|
|||||||
{/* Blur overlay */}
|
{/* Blur overlay */}
|
||||||
<div className="absolute inset-0 backdrop-blur-md bg-nofx-bg/60" />
|
<div className="absolute inset-0 backdrop-blur-md bg-nofx-bg/60" />
|
||||||
|
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
{/* Modal card */}
|
{/* Modal card */}
|
||||||
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
|
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import useSWR from 'swr'
|
import useSWR, { mutate } from 'swr'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
|
import { confirmToast, notify } from '../../lib/notify'
|
||||||
import type {
|
import type {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
AccountInfo,
|
AccountInfo,
|
||||||
@@ -53,6 +54,12 @@ function fmtPct(n: number | undefined): string {
|
|||||||
if (n == null || Number.isNaN(n)) return '—'
|
if (n == null || Number.isNaN(n)) return '—'
|
||||||
return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
|
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 {
|
function baseLabel(raw?: string): string {
|
||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '')
|
return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '')
|
||||||
@@ -77,6 +84,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 })
|
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) {
|
function useTick(ms = 1000) {
|
||||||
const [, set] = useState(0)
|
const [, set] = useState(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,6 +117,57 @@ export function TerminalDashboard({
|
|||||||
const traderId = selectedTrader?.trader_id || selectedTraderId
|
const traderId = selectedTrader?.trader_id || selectedTraderId
|
||||||
useTick(1000)
|
useTick(1000)
|
||||||
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||||
|
const [closing, setClosing] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function closePositionRow(symbol: string, side: 'LONG' | 'SHORT') {
|
||||||
|
if (!traderId || closing) return
|
||||||
|
const ok = await confirmToast(`Market-close ${symbol} ${side}?`, {
|
||||||
|
title: 'Close position',
|
||||||
|
okText: 'Close',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
setClosing(symbol)
|
||||||
|
try {
|
||||||
|
await api.closePosition(traderId, symbol, side)
|
||||||
|
notify.success(`${symbol} ${side} closed`)
|
||||||
|
await Promise.all([
|
||||||
|
mutate(`positions-${traderId}`),
|
||||||
|
mutate(`account-${traderId}`),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(err instanceof Error ? err.message : 'Close failed')
|
||||||
|
} finally {
|
||||||
|
setClosing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeAllPositions(open: Position[]) {
|
||||||
|
if (!traderId || closing || open.length === 0) return
|
||||||
|
const ok = await confirmToast(
|
||||||
|
`Market-close ALL ${open.length} open positions?`,
|
||||||
|
{ title: 'Flatten book', okText: 'Close all', cancelText: 'Cancel' }
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
setClosing('__all__')
|
||||||
|
let failed = 0
|
||||||
|
// Sequential: parallel closes race on exchange nonces / rate limits.
|
||||||
|
for (const p of open) {
|
||||||
|
const side = /long|buy/i.test(p.side) ? 'LONG' : 'SHORT'
|
||||||
|
try {
|
||||||
|
await api.closePosition(traderId, p.symbol, side)
|
||||||
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
mutate(`positions-${traderId}`),
|
||||||
|
mutate(`account-${traderId}`),
|
||||||
|
])
|
||||||
|
if (failed === 0) notify.success('All positions closed')
|
||||||
|
else notify.error(`${failed}/${open.length} closes failed`)
|
||||||
|
setClosing(null)
|
||||||
|
}
|
||||||
|
|
||||||
const { data: realFullStats } = useSWR(
|
const { data: realFullStats } = useSWR(
|
||||||
traderId ? ['full-stats', traderId] : null,
|
traderId ? ['full-stats', traderId] : null,
|
||||||
@@ -494,29 +564,76 @@ export function TerminalDashboard({
|
|||||||
<span className="tm-px" style={{ fontSize: 11 }}>Positions</span>
|
<span className="tm-px" style={{ fontSize: 11 }}>Positions</span>
|
||||||
<span className="tm-sc">Current positions · live</span>
|
<span className="tm-sc">Current positions · live</span>
|
||||||
<span className="tm-sc" style={{ marginLeft: 'auto' }}>{positions?.length ?? 0} open</span>
|
<span className="tm-sc" style={{ marginLeft: 'auto' }}>{positions?.length ?? 0} open</span>
|
||||||
|
{traderId && !on && positions && positions.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void closeAllPositions(positions)}
|
||||||
|
disabled={closing !== null}
|
||||||
|
className="tm-mono"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--tm-dn)',
|
||||||
|
color: 'var(--tm-dn)',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 9,
|
||||||
|
padding: '1px 6px',
|
||||||
|
cursor: closing ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: closing ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{closing === '__all__' ? 'closing…' : 'close all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{positions && positions.length > 0 ? (
|
{positions && positions.length > 0 ? (
|
||||||
<table className="tm-mono" style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
<table className="tm-mono" style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="tm-sc" style={{ fontSize: 9 }}>
|
<tr className="tm-sc" style={{ fontSize: 9 }}>
|
||||||
<td style={{ padding: '0 0 3px' }}>symbol</td>
|
<td style={{ padding: '0 0 3px' }}>symbol</td>
|
||||||
<td style={{ padding: '0 0 3px' }}>side</td>
|
<td style={{ padding: '0 0 3px' }}>side·lev</td>
|
||||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>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' }}>PnL</td>
|
||||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>return%</td>
|
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>return%</td>
|
||||||
|
{traderId && !on && <td style={{ padding: '0 0 3px' }} />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{positions.map((p, i) => {
|
{positions.map((p, i) => {
|
||||||
const long = /long|buy/i.test(p.side)
|
const long = /long|buy/i.test(p.side)
|
||||||
const win = (p.unrealized_pnl ?? 0) >= 0
|
const win = (p.unrealized_pnl ?? 0) >= 0
|
||||||
|
const notional = Math.abs(p.quantity ?? 0) * (p.mark_price || p.entry_price || 0)
|
||||||
return (
|
return (
|
||||||
<tr key={`${p.symbol}-${i}`} style={{ borderTop: '1px solid var(--tm-hair)' }}>
|
<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', 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' }} 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-muted)' }}>{p.leverage}×</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'}>{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>
|
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{(p.unrealized_pnl_pct ?? 0).toFixed(2)}%</td>
|
||||||
|
{traderId && !on && (
|
||||||
|
<td style={{ padding: '5px 0 5px 8px', textAlign: 'right', width: 1 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void closePositionRow(p.symbol, long ? 'LONG' : 'SHORT')}
|
||||||
|
disabled={closing !== null}
|
||||||
|
title={`Close ${p.symbol}`}
|
||||||
|
className="tm-mono"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--tm-dn)',
|
||||||
|
color: 'var(--tm-dn)',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 9,
|
||||||
|
padding: '1px 5px',
|
||||||
|
cursor: closing ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: closing ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{closing === p.symbol ? '…' : 'close'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -528,10 +645,19 @@ export function TerminalDashboard({
|
|||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
|
||||||
<span className="tm-px" style={{ fontSize: 11 }}>Recent trades</span>
|
<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>
|
</div>
|
||||||
{recentTrades.length > 0 ? (
|
{recentTrades.length > 0 ? (
|
||||||
<table className="tm-mono" style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
<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>
|
<tbody>
|
||||||
{recentTrades.map((p) => {
|
{recentTrades.map((p) => {
|
||||||
const win = p.realized_pnl >= 0
|
const win = p.realized_pnl >= 0
|
||||||
@@ -539,7 +665,8 @@ export function TerminalDashboard({
|
|||||||
<tr key={p.id} style={{ borderTop: '1px solid var(--tm-hair)' }}>
|
<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', 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' }} 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>
|
<td style={{ padding: '5px 0', textAlign: 'right' }} className={win ? 'tm-up' : 'tm-dn'}>{fmtUsd(p.realized_pnl, true)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { t, type Language } from '../../i18n/translations'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
|
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
|
||||||
|
import { HyperliquidFundsPanel } from '../common/HyperliquidFundsPanel'
|
||||||
import { getExchangeIcon } from '../common/ExchangeIcons'
|
import { getExchangeIcon } from '../common/ExchangeIcons'
|
||||||
import {
|
import {
|
||||||
TwoStageKeyModal,
|
TwoStageKeyModal,
|
||||||
@@ -708,6 +709,13 @@ export function ExchangeConfigModal({
|
|||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<HyperliquidWalletConnect language={language} isLoggedIn={Boolean(user)} variant="inline" />
|
<HyperliquidWalletConnect language={language} isLoggedIn={Boolean(user)} variant="inline" />
|
||||||
</div>
|
</div>
|
||||||
|
{selectedExchange?.hyperliquidWalletAddr && (
|
||||||
|
<HyperliquidFundsPanel
|
||||||
|
language={language}
|
||||||
|
walletAddress={selectedExchange.hyperliquidWalletAddr}
|
||||||
|
unifiedAccount={selectedExchange.hyperliquidUnifiedAccount ?? true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
|||||||
import type { AIModel } from '../../types'
|
import type { AIModel } from '../../types'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { getModelIcon } from '../common/ModelIcons'
|
import { getModelIcon, getModelColor } from '../common/ModelIcons'
|
||||||
import { ModelStepIndicator } from './ModelStepIndicator'
|
import { ModelStepIndicator } from './ModelStepIndicator'
|
||||||
import { ModelCard } from './ModelCard'
|
import { ModelCard } from './ModelCard'
|
||||||
import {
|
import {
|
||||||
BLOCKRUN_MODELS,
|
|
||||||
CLAW402_MODELS,
|
CLAW402_MODELS,
|
||||||
|
DEFAULT_CLAW402_MODEL,
|
||||||
AI_PROVIDER_CONFIG,
|
AI_PROVIDER_CONFIG,
|
||||||
getShortName,
|
getShortName,
|
||||||
} from './model-constants'
|
} from './model-constants'
|
||||||
@@ -50,11 +50,18 @@ export function ModelConfigModal({
|
|||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [modelName, setModelName] = useState('')
|
const [modelName, setModelName] = useState('')
|
||||||
|
|
||||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
// The configured entry carries the saved details (wallet address, custom
|
||||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
// model name, has_api_key); the template from supportedModels only describes
|
||||||
const selectedModel =
|
// the provider. When editing, the configured entry must win — both can share
|
||||||
allModels?.find((m) => m.id === selectedModelId) ||
|
// the same id (e.g. "claw402").
|
||||||
configuredModels?.find((m) => m.id === selectedModelId)
|
const configuredModel = configuredModels?.find((m) => m.id === selectedModelId)
|
||||||
|
const templateModel = allModels?.find((m) => m.id === selectedModelId)
|
||||||
|
const selectedModel = editingModelId
|
||||||
|
? configuredModel || templateModel
|
||||||
|
: templateModel || configuredModel
|
||||||
|
const hasExistingKey = Boolean(
|
||||||
|
configuredModel?.has_api_key || configuredModel?.apiKey
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingModelId && selectedModel) {
|
if (editingModelId && selectedModel) {
|
||||||
@@ -80,7 +87,10 @@ export function ModelConfigModal({
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedModelId || !apiKey.trim()) return
|
if (!selectedModelId) return
|
||||||
|
// Editing with a stored key: an empty key means "keep the existing one"
|
||||||
|
// (the backend preserves the stored key when api_key is empty).
|
||||||
|
if (!apiKey.trim() && !(editingModelId && hasExistingKey)) return
|
||||||
onSave(
|
onSave(
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
apiKey.trim(),
|
apiKey.trim(),
|
||||||
@@ -189,6 +199,7 @@ export function ModelConfigModal({
|
|||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
modelName={modelName}
|
modelName={modelName}
|
||||||
editingModelId={editingModelId}
|
editingModelId={editingModelId}
|
||||||
|
hasExistingKey={hasExistingKey}
|
||||||
initialWalletAddress={selectedModel.walletAddress}
|
initialWalletAddress={selectedModel.walletAddress}
|
||||||
initialBalanceUsdc={selectedModel.balanceUsdc}
|
initialBalanceUsdc={selectedModel.balanceUsdc}
|
||||||
onApiKeyChange={setApiKey}
|
onApiKeyChange={setApiKey}
|
||||||
@@ -323,7 +334,7 @@ function ModelSelectionStep({
|
|||||||
border: '1px solid rgba(46, 139, 87, 0.2)',
|
border: '1px solid rgba(46, 139, 87, 0.2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
GPT · Claude · DeepSeek · GLM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -345,39 +356,6 @@ function ModelSelectionStep({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{availableModels.some((m) => m.provider?.startsWith('blockrun')) && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<div
|
|
||||||
className="flex-1 h-px"
|
|
||||||
style={{ background: 'rgba(26,24,19,0.14)' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium px-2"
|
|
||||||
style={{ color: '#8A8478' }}
|
|
||||||
>
|
|
||||||
{t('modelConfig.viaBlockrunWallet', language)}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="flex-1 h-px"
|
|
||||||
style={{ background: 'rgba(26,24,19,0.14)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{availableModels
|
|
||||||
.filter((m) => m.provider?.startsWith('blockrun'))
|
|
||||||
.map((model) => (
|
|
||||||
<ModelCard
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
selected={selectedModelId === model.id}
|
|
||||||
onClick={() => onSelectModel(model.id)}
|
|
||||||
configured={configuredIds.has(model.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-center pt-2" style={{ color: '#8A8478' }}>
|
<div className="text-xs text-center pt-2" style={{ color: '#8A8478' }}>
|
||||||
{t('modelConfig.modelsConfigured', language)}
|
{t('modelConfig.modelsConfigured', language)}
|
||||||
</div>
|
</div>
|
||||||
@@ -389,6 +367,7 @@ function Claw402ConfigForm({
|
|||||||
apiKey,
|
apiKey,
|
||||||
modelName,
|
modelName,
|
||||||
editingModelId,
|
editingModelId,
|
||||||
|
hasExistingKey,
|
||||||
initialWalletAddress,
|
initialWalletAddress,
|
||||||
initialBalanceUsdc,
|
initialBalanceUsdc,
|
||||||
onApiKeyChange,
|
onApiKeyChange,
|
||||||
@@ -400,6 +379,7 @@ function Claw402ConfigForm({
|
|||||||
apiKey: string
|
apiKey: string
|
||||||
modelName: string
|
modelName: string
|
||||||
editingModelId: string | null
|
editingModelId: string | null
|
||||||
|
hasExistingKey?: boolean
|
||||||
initialWalletAddress?: string
|
initialWalletAddress?: string
|
||||||
initialBalanceUsdc?: string
|
initialBalanceUsdc?: string
|
||||||
onApiKeyChange: (value: string) => void
|
onApiKeyChange: (value: string) => void
|
||||||
@@ -442,6 +422,11 @@ function Claw402ConfigForm({
|
|||||||
apiKey.startsWith('0x') &&
|
apiKey.startsWith('0x') &&
|
||||||
/^0x[0-9a-fA-F]{64}$/.test(apiKey)
|
/^0x[0-9a-fA-F]{64}$/.test(apiKey)
|
||||||
|
|
||||||
|
// Editing with a stored key: allow saving (e.g. switching model) without
|
||||||
|
// re-entering the private key, as long as the field is left blank.
|
||||||
|
const canSubmit =
|
||||||
|
isKeyValid || (Boolean(editingModelId) && Boolean(hasExistingKey) && !apiKey)
|
||||||
|
|
||||||
// Truncate address for display
|
// Truncate address for display
|
||||||
|
|
||||||
// Debounced validation when apiKey changes
|
// Debounced validation when apiKey changes
|
||||||
@@ -561,7 +546,7 @@ function Claw402ConfigForm({
|
|||||||
{t('modelConfig.allModelsClaw', language)}
|
{t('modelConfig.allModelsClaw', language)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-3 mt-3 flex-wrap">
|
<div className="flex items-center justify-center gap-3 mt-3 flex-wrap">
|
||||||
{['GPT', 'Claude', 'DeepSeek', 'Gemini', 'Grok', 'Qwen', 'Kimi'].map(
|
{['GPT', 'Claude', 'DeepSeek', 'GLM'].map(
|
||||||
(name) => (
|
(name) => (
|
||||||
<span
|
<span
|
||||||
key={name}
|
key={name}
|
||||||
@@ -592,7 +577,7 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
{CLAW402_MODELS.map((m) => {
|
{CLAW402_MODELS.map((m) => {
|
||||||
const isSelected = (modelName || 'deepseek') === m.id
|
const isSelected = (modelName || DEFAULT_CLAW402_MODEL) === m.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -608,7 +593,22 @@ function Claw402ConfigForm({
|
|||||||
: '1px solid rgba(26,24,19,0.14)',
|
: '1px solid rgba(26,24,19,0.14)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-base mt-0.5">{m.icon}</span>
|
<div
|
||||||
|
className="w-7 h-7 mt-0.5 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid rgba(26,24,19,0.10)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getModelIcon(m.brand, { width: 18, height: 18 }) || (
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold"
|
||||||
|
style={{ color: getModelColor(m.brand) }}
|
||||||
|
>
|
||||||
|
{m.provider[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -636,8 +636,11 @@ function Claw402ConfigForm({
|
|||||||
>
|
>
|
||||||
{m.provider} · {m.desc}
|
{m.provider} · {m.desc}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px]" style={{ color: '#2E8B57' }}>
|
<div
|
||||||
~${m.price}/call
|
className="text-[10px] font-medium"
|
||||||
|
style={{ color: '#2E8B57' }}
|
||||||
|
>
|
||||||
|
${m.priceIn} in · ${m.priceOut} out /1M tok
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
@@ -1134,11 +1137,11 @@ function Claw402ConfigForm({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isKeyValid}
|
disabled={!canSubmit}
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
style={{
|
style={{
|
||||||
background: isKeyValid ? '#E0483B' : '#E8E2D5',
|
background: canSubmit ? '#E0483B' : '#E8E2D5',
|
||||||
color: isKeyValid ? '#fff' : '#8A8478',
|
color: canSubmit ? '#fff' : '#8A8478',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{'🚀 ' + t('modelConfig.startTrading', language)}
|
{'🚀 ' + t('modelConfig.startTrading', language)}
|
||||||
@@ -1216,9 +1219,7 @@ function StandardProviderConfigForm({
|
|||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" style={{ color: '#E0483B' }} />
|
<ExternalLink className="w-4 h-4" style={{ color: '#E0483B' }} />
|
||||||
<span className="text-sm font-medium" style={{ color: '#E0483B' }}>
|
<span className="text-sm font-medium" style={{ color: '#E0483B' }}>
|
||||||
{selectedModel.provider?.startsWith('blockrun')
|
{t('modelConfig.getApiKey', language)}
|
||||||
? t('modelConfig.getStarted', language)
|
|
||||||
: t('modelConfig.getApiKey', language)}
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -1276,9 +1277,7 @@ function StandardProviderConfigForm({
|
|||||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{selectedModel.provider?.startsWith('blockrun')
|
{'API Key *'}
|
||||||
? t('modelConfig.walletPrivateKeyLabel', language)
|
|
||||||
: 'API Key *'}
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -1287,11 +1286,7 @@ function StandardProviderConfigForm({
|
|||||||
placeholder={
|
placeholder={
|
||||||
editingModelId && selectedModel.has_api_key
|
editingModelId && selectedModel.has_api_key
|
||||||
? 'Saved. Re-enter to replace.'
|
? 'Saved. Re-enter to replace.'
|
||||||
: selectedModel.provider === 'blockrun-base'
|
: t('enterAPIKey', language)
|
||||||
? '0x... (EVM private key)'
|
|
||||||
: selectedModel.provider === 'blockrun-sol'
|
|
||||||
? 'bs58 encoded key (Solana)'
|
|
||||||
: t('enterAPIKey', language)
|
|
||||||
}
|
}
|
||||||
className="w-full px-4 py-3 rounded-xl"
|
className="w-full px-4 py-3 rounded-xl"
|
||||||
style={{
|
style={{
|
||||||
@@ -1299,13 +1294,12 @@ function StandardProviderConfigForm({
|
|||||||
border: '1px solid rgba(26,24,19,0.14)',
|
border: '1px solid rgba(26,24,19,0.14)',
|
||||||
color: '#1A1813',
|
color: '#1A1813',
|
||||||
}}
|
}}
|
||||||
required
|
required={!(editingModelId && selectedModel.has_api_key)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Base URL (hidden for BlockRun) */}
|
{/* Custom Base URL */}
|
||||||
{!selectedModel.provider?.startsWith('blockrun') && (
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
<label
|
||||||
className="flex items-center gap-2 text-sm font-semibold"
|
className="flex items-center gap-2 text-sm font-semibold"
|
||||||
style={{ color: '#1A1813' }}
|
style={{ color: '#1A1813' }}
|
||||||
@@ -1338,15 +1332,13 @@ function StandardProviderConfigForm({
|
|||||||
color: '#1A1813',
|
color: '#1A1813',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs" style={{ color: '#8A8478' }}>
|
<div className="text-xs" style={{ color: '#8A8478' }}>
|
||||||
{t('leaveBlankForDefault', language)}
|
{t('leaveBlankForDefault', language)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Custom Model Name (hidden for BlockRun) */}
|
{/* Custom Model Name */}
|
||||||
{!selectedModel.provider?.startsWith('blockrun') && (
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
<label
|
||||||
className="flex items-center gap-2 text-sm font-semibold"
|
className="flex items-center gap-2 text-sm font-semibold"
|
||||||
style={{ color: '#1A1813' }}
|
style={{ color: '#1A1813' }}
|
||||||
@@ -1379,68 +1371,10 @@ function StandardProviderConfigForm({
|
|||||||
color: '#1A1813',
|
color: '#1A1813',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs" style={{ color: '#8A8478' }}>
|
<div className="text-xs" style={{ color: '#8A8478' }}>
|
||||||
{t('leaveBlankForDefaultModel', language)}
|
{t('leaveBlankForDefaultModel', language)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* BlockRun Model Selector */}
|
|
||||||
{selectedModel.provider?.startsWith('blockrun') && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
className="flex items-center gap-2 text-sm font-semibold"
|
|
||||||
style={{ color: '#1A1813' }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
style={{ color: '#E0483B' }}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{t('modelConfig.selectModelLabel', language)}
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{BLOCKRUN_MODELS.map((m) => {
|
|
||||||
const isSelected = (modelName || BLOCKRUN_MODELS[0].id) === m.id
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onModelNameChange(m.id)}
|
|
||||||
className="flex flex-col items-start px-3 py-2 rounded-xl text-left transition-all"
|
|
||||||
style={{
|
|
||||||
background: isSelected
|
|
||||||
? 'rgba(224, 72, 59, 0.12)'
|
|
||||||
: '#F1ECE2',
|
|
||||||
border: isSelected
|
|
||||||
? '1px solid #E0483B'
|
|
||||||
: '1px solid rgba(26,24,19,0.14)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-xs font-semibold"
|
|
||||||
style={{ color: isSelected ? '#E0483B' : '#1A1813' }}
|
|
||||||
>
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px]" style={{ color: '#8A8478' }}>
|
|
||||||
{m.desc}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div
|
<div
|
||||||
@@ -1478,7 +1412,11 @@ function StandardProviderConfigForm({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!selectedModel || !apiKey.trim()}
|
disabled={
|
||||||
|
!selectedModel ||
|
||||||
|
(!apiKey.trim() &&
|
||||||
|
!(editingModelId && selectedModel.has_api_key))
|
||||||
|
}
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
style={{ background: '#E0483B', color: '#fff' }}
|
style={{ background: '#E0483B', color: '#fff' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ export interface Claw402Model {
|
|||||||
name: string
|
name: string
|
||||||
provider: string
|
provider: string
|
||||||
desc: string
|
desc: string
|
||||||
icon: string
|
brand: string // key for getModelIcon / getModelColor
|
||||||
price: number // USD per call
|
priceIn: number // USD per 1M input tokens (upto pay-as-you-go)
|
||||||
|
priceOut: number // USD per 1M output tokens
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,12 +17,6 @@ export interface AIProviderConfig {
|
|||||||
apiName: string
|
apiName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockrunModel {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
desc: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get friendly AI model display name
|
// Get friendly AI model display name
|
||||||
export function getModelDisplayName(modelId: string): string {
|
export function getModelDisplayName(modelId: string): string {
|
||||||
switch (modelId.toLowerCase()) {
|
switch (modelId.toLowerCase()) {
|
||||||
@@ -42,93 +37,25 @@ export function getShortName(fullName: string): string {
|
|||||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CLAW402_MODEL = 'deepseek-v4-flash'
|
export const DEFAULT_CLAW402_MODEL = 'gpt-5.6'
|
||||||
|
|
||||||
// Models available through Claw402 (x402 USDC payment protocol)
|
// Models available through Claw402 (x402 USDC payment protocol)
|
||||||
|
// Must stay in sync with the claw402 catalog (GET /api/v1/catalog)
|
||||||
|
// Prices are USD per 1M tokens (input / output), settled pay-as-you-go via
|
||||||
|
// the x402 upto scheme — each call is charged on actual token usage.
|
||||||
export const CLAW402_MODELS: Claw402Model[] = [
|
export const CLAW402_MODELS: Claw402Model[] = [
|
||||||
{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'DeepSeek', desc: '$0.003/call', icon: '⚡', price: 0.003, isNew: true },
|
{ id: 'gpt-5.6', name: 'GPT-5.6 Sol', provider: 'OpenAI', desc: 'Flagship', brand: 'openai', priceIn: 5, priceOut: 30 },
|
||||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', provider: 'DeepSeek', desc: '$0.01/call', icon: '🧠', price: 0.01, isNew: true },
|
{ id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', provider: 'OpenAI', desc: 'Balanced', brand: 'openai', priceIn: 2.5, priceOut: 15 },
|
||||||
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: '$0.003/call', icon: '🔥', price: 0.003 },
|
{ id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', provider: 'OpenAI', desc: 'Cost-efficient', brand: 'openai', priceIn: 1, priceOut: 6 },
|
||||||
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: '$0.005/call', icon: '🤔', price: 0.005 },
|
{ id: 'claude-fable', name: 'Claude Fable 5', provider: 'Anthropic', desc: 'Most capable', brand: 'claude', priceIn: 10, priceOut: 50 },
|
||||||
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: '$0.005/call', icon: '🚀', price: 0.005 },
|
{ id: 'claude-opus', name: 'Claude Opus 4.8', provider: 'Anthropic', desc: 'Coding & agents flagship', brand: 'claude', priceIn: 5, priceOut: 25 },
|
||||||
{ id: 'qwen-turbo', name: 'Qwen Turbo', provider: 'Alibaba', desc: '$0.002/call', icon: '⚡', price: 0.002 },
|
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4 Flash', provider: 'DeepSeek', desc: 'Fast general model', brand: 'deepseek', priceIn: 0.14, priceOut: 0.28 },
|
||||||
{ id: 'qwen-flash', name: 'Qwen Flash', provider: 'Alibaba', desc: '$0.002/call', icon: '⚡', price: 0.002 },
|
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4 Pro', provider: 'DeepSeek', desc: 'Advanced reasoning', brand: 'deepseek', priceIn: 1.74, priceOut: 3.48 },
|
||||||
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: '$0.005/call', icon: '✨', price: 0.005 },
|
{ id: 'glm-5', name: 'GLM-5', provider: 'Z.ai', desc: 'Deep reasoning flagship', brand: 'zhipu', priceIn: 0.6, priceOut: 2 },
|
||||||
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: '$0.008/call', icon: '🌙', price: 0.008 },
|
|
||||||
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: '$0.01/call', icon: '💡', price: 0.01 },
|
|
||||||
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: '$0.01/call', icon: '🌟', price: 0.01 },
|
|
||||||
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: '$0.03/call', icon: '💎', price: 0.03 },
|
|
||||||
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: '$0.05/call', icon: '⚡', price: 0.05 },
|
|
||||||
{ id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: '$0.06/call', icon: '⚡', price: 0.06 },
|
|
||||||
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: '$0.12/call', icon: '🎯', price: 0.12 },
|
|
||||||
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: '$0.50/call', icon: '🧠', price: 0.50 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const BLOCKRUN_MODELS: BlockrunModel[] = [
|
|
||||||
{
|
|
||||||
id: 'gpt-5.2',
|
|
||||||
name: 'GPT-5.2',
|
|
||||||
desc: 'Base wallet payment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'claude-opus-4-6',
|
|
||||||
name: 'Claude Opus 4.6',
|
|
||||||
desc: 'Base wallet payment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'gemini-3.1-pro',
|
|
||||||
name: 'Gemini 3.1 Pro',
|
|
||||||
desc: 'Base wallet payment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'qwen3-max',
|
|
||||||
name: 'Qwen 3 Max',
|
|
||||||
desc: 'Base wallet payment',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// AI Provider configuration - default models and API links
|
// AI Provider configuration - default models and API links
|
||||||
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
|
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
|
||||||
deepseek: {
|
|
||||||
defaultModel: 'deepseek-chat',
|
|
||||||
apiUrl: 'https://platform.deepseek.com/api_keys',
|
|
||||||
apiName: 'DeepSeek',
|
|
||||||
},
|
|
||||||
qwen: {
|
|
||||||
defaultModel: 'qwen3-max',
|
|
||||||
apiUrl: 'https://dashscope.console.aliyun.com/apiKey',
|
|
||||||
apiName: 'Alibaba Cloud',
|
|
||||||
},
|
|
||||||
openai: {
|
|
||||||
defaultModel: 'gpt-5.2',
|
|
||||||
apiUrl: 'https://platform.openai.com/api-keys',
|
|
||||||
apiName: 'OpenAI',
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
defaultModel: 'claude-opus-4-6',
|
|
||||||
apiUrl: 'https://console.anthropic.com/settings/keys',
|
|
||||||
apiName: 'Anthropic',
|
|
||||||
},
|
|
||||||
gemini: {
|
|
||||||
defaultModel: 'gemini-3-pro-preview',
|
|
||||||
apiUrl: 'https://aistudio.google.com/app/apikey',
|
|
||||||
apiName: 'Google AI Studio',
|
|
||||||
},
|
|
||||||
grok: {
|
|
||||||
defaultModel: 'grok-3-latest',
|
|
||||||
apiUrl: 'https://console.x.ai/',
|
|
||||||
apiName: 'xAI',
|
|
||||||
},
|
|
||||||
kimi: {
|
|
||||||
defaultModel: 'moonshot-v1-auto',
|
|
||||||
apiUrl: 'https://platform.moonshot.ai/console/api-keys',
|
|
||||||
apiName: 'Moonshot',
|
|
||||||
},
|
|
||||||
minimax: {
|
|
||||||
defaultModel: 'MiniMax-M2.7',
|
|
||||||
apiUrl: 'https://platform.minimax.io',
|
|
||||||
apiName: 'MiniMax',
|
|
||||||
},
|
|
||||||
claw402: {
|
claw402: {
|
||||||
defaultModel: DEFAULT_CLAW402_MODEL,
|
defaultModel: DEFAULT_CLAW402_MODEL,
|
||||||
apiUrl: 'https://claw402.ai',
|
apiUrl: 'https://claw402.ai',
|
||||||
|
|||||||
@@ -1015,6 +1015,11 @@ export const translations = {
|
|||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
positionClosed: 'Position closed successfully',
|
positionClosed: 'Position closed successfully',
|
||||||
closeFailed: 'Failed to close position',
|
closeFailed: 'Failed to close position',
|
||||||
|
closeAll: 'Close All',
|
||||||
|
confirmCloseAllPositions:
|
||||||
|
'Market-close ALL {count} open positions?',
|
||||||
|
allPositionsClosed: 'All positions closed',
|
||||||
|
closeAllPartial: '{failed} of {count} positions failed to close',
|
||||||
hideAddress: 'Hide address',
|
hideAddress: 'Hide address',
|
||||||
showFullAddress: 'Show full address',
|
showFullAddress: 'Show full address',
|
||||||
copyAddress: 'Copy address',
|
copyAddress: 'Copy address',
|
||||||
@@ -1087,9 +1092,9 @@ export const translations = {
|
|||||||
otherApiEntry: 'Other API Providers',
|
otherApiEntry: 'Other API Providers',
|
||||||
otherApiEntryDesc:
|
otherApiEntryDesc:
|
||||||
'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
|
'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
|
||||||
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
|
payPerCall: 'Pay-as-you-go USDC · All AI Models · No API Key',
|
||||||
recommended: 'Best',
|
recommended: 'Best',
|
||||||
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
allModelsClaw: 'Pay-as-you-go with USDC — supports all major AI models',
|
||||||
selectAiModel: 'Choose AI Model',
|
selectAiModel: 'Choose AI Model',
|
||||||
allModelsUnified:
|
allModelsUnified:
|
||||||
'All models unified via Claw402. Switch anytime after setup.',
|
'All models unified via Claw402. Switch anytime after setup.',
|
||||||
@@ -2181,6 +2186,10 @@ export const translations = {
|
|||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
positionClosed: '平仓成功',
|
positionClosed: '平仓成功',
|
||||||
closeFailed: '平仓失败',
|
closeFailed: '平仓失败',
|
||||||
|
closeAll: '一键全平',
|
||||||
|
confirmCloseAllPositions: '确定要市价平掉全部 {count} 个持仓吗?',
|
||||||
|
allPositionsClosed: '全部持仓已平',
|
||||||
|
closeAllPartial: '{count} 个持仓中有 {failed} 个平仓失败',
|
||||||
hideAddress: '隐藏地址',
|
hideAddress: '隐藏地址',
|
||||||
showFullAddress: '显示完整地址',
|
showFullAddress: '显示完整地址',
|
||||||
copyAddress: '复制地址',
|
copyAddress: '复制地址',
|
||||||
@@ -2250,9 +2259,9 @@ export const translations = {
|
|||||||
otherApiEntry: '其他 API 模型',
|
otherApiEntry: '其他 API 模型',
|
||||||
otherApiEntryDesc:
|
otherApiEntryDesc:
|
||||||
'如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
|
'如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
|
||||||
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
payPerCall: 'USDC 按量付费 · 支持全部 AI 模型 · 无需 API Key',
|
||||||
recommended: '推荐',
|
recommended: '推荐',
|
||||||
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
allModelsClaw: '用 USDC 按量付费,支持所有主流 AI 模型',
|
||||||
selectAiModel: '① 选择 AI 模型',
|
selectAiModel: '① 选择 AI 模型',
|
||||||
allModelsUnified: '所有模型通过 Claw402 统一调用,创建后可随时切换',
|
allModelsUnified: '所有模型通过 Claw402 统一调用,创建后可随时切换',
|
||||||
setupWallet: '② 设置钱包',
|
setupWallet: '② 设置钱包',
|
||||||
@@ -3293,6 +3302,11 @@ export const translations = {
|
|||||||
cancel: 'Batal',
|
cancel: 'Batal',
|
||||||
positionClosed: 'Posisi berhasil ditutup',
|
positionClosed: 'Posisi berhasil ditutup',
|
||||||
closeFailed: 'Gagal menutup posisi',
|
closeFailed: 'Gagal menutup posisi',
|
||||||
|
closeAll: 'Tutup Semua',
|
||||||
|
confirmCloseAllPositions:
|
||||||
|
'Tutup SEMUA {count} posisi terbuka dengan harga pasar?',
|
||||||
|
allPositionsClosed: 'Semua posisi ditutup',
|
||||||
|
closeAllPartial: '{failed} dari {count} posisi gagal ditutup',
|
||||||
hideAddress: 'Sembunyikan alamat',
|
hideAddress: 'Sembunyikan alamat',
|
||||||
showFullAddress: 'Tampilkan alamat lengkap',
|
showFullAddress: 'Tampilkan alamat lengkap',
|
||||||
copyAddress: 'Salin alamat',
|
copyAddress: 'Salin alamat',
|
||||||
@@ -3363,7 +3377,7 @@ export const translations = {
|
|||||||
otherApiEntry: 'Penyedia API Lain',
|
otherApiEntry: 'Penyedia API Lain',
|
||||||
otherApiEntryDesc:
|
otherApiEntryDesc:
|
||||||
'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
|
'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
|
||||||
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
|
payPerCall: 'Bayar sesuai pemakaian USDC · Semua Model AI · Tanpa API Key',
|
||||||
recommended: 'Terbaik',
|
recommended: 'Terbaik',
|
||||||
allModelsClaw:
|
allModelsClaw:
|
||||||
'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export interface HyperliquidAccountSummary {
|
|||||||
totalMarginUsed: number
|
totalMarginUsed: number
|
||||||
unrealizedPnl: number
|
unrealizedPnl: number
|
||||||
openPositions: number
|
openPositions: number
|
||||||
|
/** Spot ("main wallet") USDC balance on Hyperliquid */
|
||||||
|
spotUsdc: number
|
||||||
|
/** Spot USDC not locked by open orders, transferable to perp */
|
||||||
|
spotUsdcAvailable: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
143
web/src/lib/hyperliquidWallet.ts
Normal file
143
web/src/lib/hyperliquidWallet.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for Hyperliquid wallet flows: injected EVM provider
|
||||||
|
* discovery, EIP-712 typed-data construction for Hyperliquid user-signed
|
||||||
|
* actions, and signature handling. Used by the connect/onboarding flow and
|
||||||
|
* the deposit/transfer funds panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ethereum?: WalletProvider & { providers?: WalletProvider[] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WalletProvider = {
|
||||||
|
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
|
||||||
|
on?: (event: string, handler: (...args: unknown[]) => void) => void
|
||||||
|
removeListener?: (
|
||||||
|
event: string,
|
||||||
|
handler: (...args: unknown[]) => void
|
||||||
|
) => void
|
||||||
|
isMetaMask?: boolean
|
||||||
|
isRabby?: boolean
|
||||||
|
isOkxWallet?: boolean
|
||||||
|
isCoinbaseWallet?: boolean
|
||||||
|
isTrust?: boolean
|
||||||
|
isPhantom?: boolean
|
||||||
|
isBackpack?: boolean
|
||||||
|
isBraveWallet?: boolean
|
||||||
|
isExodus?: boolean
|
||||||
|
isFrame?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWalletProviders(): WalletProvider[] {
|
||||||
|
const injected = window.ethereum
|
||||||
|
if (!injected) return []
|
||||||
|
const providers =
|
||||||
|
Array.isArray(injected.providers) && injected.providers.length > 0
|
||||||
|
? injected.providers
|
||||||
|
: [injected]
|
||||||
|
const seen = new Set<WalletProvider>()
|
||||||
|
return providers.filter((provider) => {
|
||||||
|
if (!provider || seen.has(provider)) return false
|
||||||
|
seen.add(provider)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreferredWalletProvider(): WalletProvider | undefined {
|
||||||
|
const providers = getWalletProviders()
|
||||||
|
return (
|
||||||
|
providers.find((provider) => provider.isRabby) ||
|
||||||
|
providers.find((provider) => provider.isMetaMask) ||
|
||||||
|
providers.find((provider) => provider.isCoinbaseWallet) ||
|
||||||
|
providers.find((provider) => provider.isPhantom) ||
|
||||||
|
providers.find((provider) => provider.isBraveWallet) ||
|
||||||
|
providers.find((provider) => provider.isBackpack) ||
|
||||||
|
providers.find((provider) => provider.isOkxWallet) ||
|
||||||
|
providers.find((provider) => provider.isTrust) ||
|
||||||
|
providers.find((provider) => provider.isExodus) ||
|
||||||
|
providers.find((provider) => provider.isFrame) ||
|
||||||
|
providers[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAddress(address: string) {
|
||||||
|
return address.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortAddress(address?: string) {
|
||||||
|
if (!address) return ''
|
||||||
|
return `${address.slice(0, 6)}…${address.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUSDC(value?: number) {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitSignature(signature: string) {
|
||||||
|
const hex = signature.startsWith('0x') ? signature.slice(2) : signature
|
||||||
|
if (hex.length !== 130) {
|
||||||
|
throw new Error('Invalid wallet signature length')
|
||||||
|
}
|
||||||
|
const v = parseInt(hex.slice(128, 130), 16)
|
||||||
|
return {
|
||||||
|
r: `0x${hex.slice(0, 64)}`,
|
||||||
|
s: `0x${hex.slice(64, 128)}`,
|
||||||
|
v: v < 27 ? v + 27 : v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTypedData(
|
||||||
|
primaryType: string,
|
||||||
|
fields: { name: string; type: string }[],
|
||||||
|
message: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
domain: {
|
||||||
|
name: 'HyperliquidSignTransaction',
|
||||||
|
version: '1',
|
||||||
|
chainId: 421614,
|
||||||
|
verifyingContract: '0x0000000000000000000000000000000000000000',
|
||||||
|
},
|
||||||
|
types: {
|
||||||
|
EIP712Domain: [
|
||||||
|
{ name: 'name', type: 'string' },
|
||||||
|
{ name: 'version', type: 'string' },
|
||||||
|
{ name: 'chainId', type: 'uint256' },
|
||||||
|
{ name: 'verifyingContract', type: 'address' },
|
||||||
|
],
|
||||||
|
[primaryType]: fields,
|
||||||
|
},
|
||||||
|
primaryType,
|
||||||
|
message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a Hyperliquid user-signed action with the connected wallet and return
|
||||||
|
* the split signature. `signerAddress` must be the wallet that owns the
|
||||||
|
* Hyperliquid account — user-signed actions derive the acting account from
|
||||||
|
* the signature itself.
|
||||||
|
*/
|
||||||
|
export async function signHyperliquidUserAction(
|
||||||
|
provider: WalletProvider,
|
||||||
|
signerAddress: string,
|
||||||
|
action: Record<string, unknown>,
|
||||||
|
primaryType: string,
|
||||||
|
fields: { name: string; type: string }[]
|
||||||
|
) {
|
||||||
|
const typedData = buildTypedData(primaryType, fields, action)
|
||||||
|
const raw = await provider.request({
|
||||||
|
method: 'eth_signTypedData_v4',
|
||||||
|
params: [signerAddress, JSON.stringify(typedData)],
|
||||||
|
})
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new Error('Wallet returned an invalid signature')
|
||||||
|
}
|
||||||
|
return splitSignature(raw)
|
||||||
|
}
|
||||||
@@ -1391,8 +1391,10 @@ export function StrategyStudioPage() {
|
|||||||
max_positions: 2,
|
max_positions: 2,
|
||||||
btc_eth_max_leverage: 10,
|
btc_eth_max_leverage: 10,
|
||||||
altcoin_max_leverage: 10,
|
altcoin_max_leverage: 10,
|
||||||
btc_eth_max_position_value_ratio: 10,
|
// Few, concentrated positions held for big moves. 10x leverage keeps a
|
||||||
altcoin_max_position_value_ratio: 10,
|
// wide (-5%) stop survivable; 2 positions × 5x = 10x total.
|
||||||
|
btc_eth_max_position_value_ratio: 5,
|
||||||
|
altcoin_max_position_value_ratio: 5,
|
||||||
max_margin_usage: 1.0,
|
max_margin_usage: 1.0,
|
||||||
min_confidence: 78,
|
min_confidence: 78,
|
||||||
min_risk_reward_ratio: 3,
|
min_risk_reward_ratio: 3,
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export function TraderDashboardPage({
|
|||||||
exchanges,
|
exchanges,
|
||||||
}: TraderDashboardPageProps) {
|
}: TraderDashboardPageProps) {
|
||||||
const [closingPosition, setClosingPosition] = useState<string | null>(null)
|
const [closingPosition, setClosingPosition] = useState<string | null>(null)
|
||||||
|
const [closingAll, setClosingAll] = useState(false)
|
||||||
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>(undefined)
|
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>(undefined)
|
||||||
const [chartUpdateKey, setChartUpdateKey] = useState<number>(0)
|
const [chartUpdateKey, setChartUpdateKey] = useState<number>(0)
|
||||||
const chartSectionRef = useRef<HTMLDivElement>(null)
|
const chartSectionRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -231,6 +232,52 @@ export function TraderDashboardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCloseAllPositions = async () => {
|
||||||
|
if (!selectedTraderId || !positions || positions.length === 0) return
|
||||||
|
|
||||||
|
const count = String(positions.length)
|
||||||
|
const confirmed = await confirmToast(
|
||||||
|
t('traderDashboard.confirmCloseAllPositions', language, { count }),
|
||||||
|
{
|
||||||
|
title: t('traderDashboard.confirmClose', language),
|
||||||
|
okText: t('traderDashboard.confirm', language),
|
||||||
|
cancelText: t('traderDashboard.cancel', language),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setClosingAll(true)
|
||||||
|
let failed = 0
|
||||||
|
// Sequential on purpose: parallel closes on the same account can race
|
||||||
|
// on exchange nonces (Hyperliquid) and rate limits.
|
||||||
|
for (const pos of positions) {
|
||||||
|
try {
|
||||||
|
await api.closePosition(
|
||||||
|
selectedTraderId,
|
||||||
|
pos.symbol,
|
||||||
|
pos.side.toUpperCase()
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
mutate(`positions-${selectedTraderId}`),
|
||||||
|
mutate(`account-${selectedTraderId}`),
|
||||||
|
])
|
||||||
|
if (failed === 0) {
|
||||||
|
notify.success(t('traderDashboard.allPositionsClosed', language))
|
||||||
|
} else {
|
||||||
|
notify.error(
|
||||||
|
t('traderDashboard.closeAllPartial', language, {
|
||||||
|
failed: String(failed),
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setClosingAll(false)
|
||||||
|
}
|
||||||
|
|
||||||
// If API failed with error, show empty state (likely backend not running)
|
// If API failed with error, show empty state (likely backend not running)
|
||||||
if (tradersError) {
|
if (tradersError) {
|
||||||
return (
|
return (
|
||||||
@@ -588,8 +635,24 @@ export function TraderDashboardPage({
|
|||||||
<span className="text-blue-500">◈</span> {t('currentPositions', language)}
|
<span className="text-blue-500">◈</span> {t('currentPositions', language)}
|
||||||
</h2>
|
</h2>
|
||||||
{positions && positions.length > 0 && (
|
{positions && positions.length > 0 && (
|
||||||
<div className="text-xs px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 font-mono">
|
<div className="flex items-center gap-2">
|
||||||
{positions.length} {t('active', language)}
|
<div className="text-xs px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 font-mono">
|
||||||
|
{positions.length} {t('active', language)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCloseAllPositions()}
|
||||||
|
disabled={closingAll || closingPosition !== null}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-nofx-danger/10 text-nofx-danger border border-nofx-danger/30 hover:bg-nofx-danger/20"
|
||||||
|
title={t('traderDashboard.closeAll', language)}
|
||||||
|
>
|
||||||
|
{closingAll ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{t('traderDashboard.closeAll', language)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user