mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 06:22:49 +08:00
Compare commits
19 Commits
21407030ea
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5277c0fb9 | ||
|
|
1b518839ab | ||
|
|
7b52ea8f78 | ||
|
|
574ddfb1ae | ||
|
|
434301cb09 | ||
|
|
05899d5afe | ||
|
|
48d7835c48 | ||
|
|
4806ee83f7 | ||
|
|
30f8f35cda | ||
|
|
2c5a5e7ab2 | ||
|
|
6195803e9e | ||
|
|
5cd62e3c3a | ||
|
|
ed3bebf287 | ||
|
|
4881c27c44 | ||
|
|
39eac5aca7 | ||
|
|
0f3e71560c | ||
|
|
eabd279d10 | ||
|
|
09b7ac9e92 | ||
|
|
dc68884559 |
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
|
||||||
|
|||||||
@@ -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,10 +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
|
||||||
// 4× equity notional per position: at 10x leverage two full positions
|
// Few, concentrated positions held for big moves. 10x leverage keeps a
|
||||||
// use ~80% of margin — concentrated but solvent.
|
// wide (-5%) stop survivable (~-50% margin, ~10% liquidation cushion);
|
||||||
c.RiskControl.BTCETHMaxPositionValueRatio = 4.0
|
// 2 positions × 5x = 10x total notional (full margin, doubled exposure).
|
||||||
c.RiskControl.AltcoinMaxPositionValueRatio = 4.0
|
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 != 4 ||
|
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 5.0 ||
|
||||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 4 ||
|
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 5.0 ||
|
||||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||||
t.Fatalf("default strategy should size Claw402 opens at 4x equity notional (two positions ≈ 80%% margin at 10x), 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 == "" {
|
||||||
|
|||||||
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()})
|
||||||
@@ -1014,11 +1014,11 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
PriceRankingLimit: 10,
|
PriceRankingLimit: 10,
|
||||||
},
|
},
|
||||||
RiskControl: RiskControlConfig{
|
RiskControl: RiskControlConfig{
|
||||||
MaxPositions: 2, // Concentrated book: two full-size positions (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: 4.0, // Per-position notional = equity × 4; at 10x two positions ≈ 80% margin
|
BTCETHMaxPositionValueRatio: 5.0, // Per-position notional = equity × 5; 2 positions = 10x total (full margin at 10x, ~10% liquidation cushion)
|
||||||
AltcoinMaxPositionValueRatio: 4.0, // Per-position notional = equity × 4; at 10x two positions ≈ 80% 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -83,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(() => {
|
||||||
@@ -104,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,
|
||||||
@@ -500,6 +564,26 @@ 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 }}>
|
||||||
@@ -511,6 +595,7 @@ export function TerminalDashboard({
|
|||||||
<td style={{ padding: '0 0 3px', textAlign: 'right' }}>size</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>
|
||||||
@@ -526,6 +611,29 @@ export function TerminalDashboard({
|
|||||||
<td style={{ padding: '5px 0', textAlign: 'right', color: 'var(--tm-ink-2)' }}>{fmtUsd(notional)}</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -537,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
|
||||||
@@ -548,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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: '复制地址',
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,10 +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,
|
||||||
// 4× equity notional per position — at 10x leverage two full
|
// Few, concentrated positions held for big moves. 10x leverage keeps a
|
||||||
// positions use ~80% of margin (concentrated but solvent)
|
// wide (-5%) stop survivable; 2 positions × 5x = 10x total.
|
||||||
btc_eth_max_position_value_ratio: 4,
|
btc_eth_max_position_value_ratio: 5,
|
||||||
altcoin_max_position_value_ratio: 4,
|
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