19 Commits

Author SHA1 Message Date
tinkle-community
c5277c0fb9 security: fix Actions code injection in PR comment workflow, add least-privilege permissions
pr-checks-comment.yml runs in the privileged workflow_run context with a
write token while consuming artifacts produced by the untrusted PR
workflow. Seven spots template-interpolated that untrusted data (and the
fork-controlled head branch name) directly into github-script source —
a crafted artifact could escape the string literal and run arbitrary JS
with the privileged token (CodeQL actions/code-injection, critical).

- All untrusted values now flow through env vars and process.env; the PR
  number is parsed and validated before use
- test.yml / docker-build.yml gain workflow-level 'permissions:
  contents: read' (CodeQL actions/missing-workflow-permissions); publish
  jobs keep their job-level packages:write
2026-07-27 00:48:10 +09:00
tinkle-community
1b518839ab chore: clear high-severity npm advisories
- react-router/react-router-dom 7.17.0 -> 7.18.1 (route-matching DoS,
  GHSA-chx6-hx7r-mcp5)
- axios 1.16.1 -> 1.18.1 (inherited-proxy leak, GHSA-gcfj-64vw-6mp9)
- postcss 8.5.15 -> 8.5.23 (source-map path traversal, GHSA-r28c-9q8g-f849)
- brace-expansion pinned to 1.1.16 / 2.1.2 across all nested copies via
  overrides (exponential-expansion DoS, GHSA-3jxr-9vmj-r5cp)

Remaining GHSA-qwww-vcr4-c8h2 (react-router RSC-mode CSRF) is dismissed
on GitHub as not-used: the app is a Vite SPA, RSC mode is never enabled,
and the fix requires a react-router v8 major upgrade.
2026-07-27 00:36:28 +09:00
tinkle-community
7b52ea8f78 chore: bump golang.org/x/crypto 0.51.0 -> 0.52.0
Clears all 7 critical Dependabot alerts (SSH subsystem: auth bypass via
unenforced @revoked status, VerifiedPublicKeyCallback permission skip,
key-constraint enforcement gaps, server deadlock and infinite-loop DoS).
Transitive dependency only — nothing in NOFX imports x/crypto directly.
2026-07-27 00:27:09 +09:00
tinkle-community
574ddfb1ae revert: drop exit-gate configurability, hardcode the replay-validated values
Operator call: no per-strategy configurability for exit pacing — it added
config fields, UI and plumbing nobody wanted. Back to plain constants in
auto_trader_throttle.go, now set to the replay-validated values instead
of the original guesses (4154 cycles, 3-fold robust search over only the
7 exit params, everything else fixed at the live config):

- min hold 90m, noise-close window 3h, noise band -2%..+3%,
  bypasses -3%/+8% (all inside the searched top-20 ranges)
- re-entry cooldown 4h — the one clear signal: top-20 configs cluster
  tightly at 3.8-4.0h; re-entering a just-closed symbol was a consistent
  loss source

Removes the 7 RiskControlConfig fields + accessors, the Strategy Studio
'exit pacing' row, the frontend type fields, and the fields from stored
strategy configs in data/data.db. Prompt guidance is static text again,
matching the constants.
2026-07-26 13:27:59 +09:00
tinkle-community
434301cb09 feat: make exit throttle gates strategy-configurable, soften defaults
The anti-churn exit gates (min hold, noise-close window, re-entry
cooldown, bypass and noise-band thresholds) were hardcoded constants in
auto_trader_throttle.go — every flat-ish position was forced to hold 8h+
and changing the pacing meant a code change and redeploy.

- RiskControlConfig gains 7 exit-gate fields (minutes / signed price-%),
  zero = built-in default; accessor methods centralize fallbacks and are
  hot-reloaded from the DB like the rest of the strategy config
- Throttle reads the gates from the strategy; prompt hold/exit guidance
  is now rendered from the same values so the AI is told exactly what
  the code will enforce
- Defaults softened: min hold 4h -> 1.5h, noise window 8h -> 3h,
  re-entry 3h -> 1.5h, bypasses -5/+12 -> -3/+8, noise band -4..+6 ->
  -2..+3 (price-basis). A +-2-3% move is 15-20x round-trip fees — no
  longer 'noise' worth locking
- Strategy Studio gets an 'exit pacing' row (min hold / flat-close
  window / re-entry cooldown); thresholds editable via strategy JSON
- Live strategy updated in data/data.db with the softened values
2026-07-25 19:26:04 +09:00
tinkle-community
05899d5afe feat: close / close-all buttons in the terminal 'Current positions · live' panel
The one-click close only existed on the legacy dashboard's positions
table; the terminal dashboard the user actually sees had a read-only
positions panel. Adds a per-row 'close' button (confirm -> market close
via the existing /close-position API) and a 'close all' button in the
panel header (single confirm, sequential closes to avoid nonce races,
partial-failure report). Hidden in demo mode and when no trader is
selected; SWR keys refresh positions and account after closing.
2026-07-24 15:40:06 +09:00
tinkle-community
48d7835c48 feat: close-all button on the dashboard positions panel
Per-position one-click close already existed (Action column, with
confirm), but flattening the whole book took one confirm per position.
Adds a 'Close All' button next to the position count: single confirm,
then closes every position sequentially (parallel closes can race on
exchange nonces/rate limits), refreshes via SWR and reports a partial-
failure count. zh/en/id translations included.
2026-07-24 15:23:14 +09:00
tinkle-community
4806ee83f7 fix: respect Hyperliquid unified accounts — drop the spot/perp split and transfer tab
Per HL's account-abstraction-modes docs, unified accounts (the default and
HL's recommendation) hold ONE USDC balance that collateralizes spot,
validator perps and HIP-3 perps — there is no spot/perp split and no class
transfer. The panel was presenting manual-mode concepts (spot vs perp
cards, a spot->perp transfer tab) to unified users: confusing and wrong.

- unifiedAccount prop (from the exchange's hyperliquid_unified_account,
  default true): hides the transfer tab and shows a single
  'Hyperliquid account' card — total / tradable / margin in use, derived
  from the spot balance and its hold
- Manual-mode accounts keep the split cards and transfer tab
- Deposit copy simplified: funds are tradable right after the bridge
  credit, no extra step
2026-07-24 15:01:53 +09:00
tinkle-community
30f8f35cda feat: show on-chain Arbitrum wallet balance in the deposit panel
The deposit tab told users to deposit wallet USDC but never showed how
much the wallet held — the 328 USDC sitting on Arbitrum was invisible,
so the deposit flow looked like there was nothing to deposit.

- Third balance card 'Wallet (Arbitrum)': native USDC balance + ETH gas
  (red warning when gas is zero), fetched directly from the public
  Arbitrum RPC (balanceOf + eth_getBalance), refreshed with the panel
- 'Depositable' amount shown next to the bridge form + Max button
- Client-side check: deposit amount cannot exceed the wallet balance
2026-07-24 14:50:43 +09:00
tinkle-community
2c5a5e7ab2 fix: correct deposit flow — add one-click Bridge2 deposit, fix misleading copy
The deposit tab claimed USDC sent to the user's own address on Arbitrum
is auto-credited to the perp account. Wrong: sending to your own address
only funds the wallet. Hyperliquid's canonical deposit is native USDC on
Arbitrum One sent to the validator-controlled Bridge2 contract
(0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7), which credits the SENDER's
account (min 5 USDC, ~1 min) — verified against Arbiscan and HL docs.

- Deposit tab now explains the two-step flow (fund wallet -> bridge in)
  and adds a one-click deposit: switches the wallet to Arbitrum One and
  sends an ERC-20 transfer of native USDC to Bridge2
- Enforces the connected wallet == configured main wallet (the bridge
  credits the sender, so any other wallet would fund the wrong account)
- Blocks sub-minimum amounts client-side (they would be lost)
2026-07-24 14:28:14 +09:00
tinkle-community
6195803e9e feat: Hyperliquid deposit QR + spot<->perp transfer in the platform
The trading account could only be funded outside NOFX: no way to move
spot ('main wallet') USDC into the perp trading account, and no deposit
address/QR shown anywhere.

- API: /hyperliquid/account now also returns spot USDC (total/available)
  alongside the perp summary; /hyperliquid/submit-exchange accepts a
  validated usdClassTransfer action (positive plain amount, boolean
  toPerp, Mainnet chain) relayed like the existing approval actions
- Web: new HyperliquidFundsPanel in the exchange config modal with a
  deposit tab (wallet address QR + Arbitrum/spot routing notes) and a
  transfer tab (spot<->perp, signed by the user's own wallet via EIP-712
  UsdClassTransfer — user-signed actions derive the account from the
  signature, so the agent key NOFX holds cannot and does not move funds)
- Shared EIP-712/provider helpers extracted from HyperliquidWalletConnect
  into lib/hyperliquidWallet.ts
2026-07-24 14:07:13 +09:00
tinkle-community
5cd62e3c3a fix: exit-guard thresholds were margin-basis, tightening with leverage
Live decomposition of the 2026-07-23 drawdown (equity 62.6 -> 31.1 in one
day, 43 fills, avg hold <1h despite a 4h min hold): both exit guards
compared thresholds meant as price moves against leverage-multiplied
margin PnL%, so raising leverage 5x -> 10x silently halved every trigger
distance:

- throttle bypass: -5% 'stop' unlocked AI closes at a -0.5% price wiggle
  (ETH -0.5%/44min, AAVE -0.9%/45min, MU -2.4%/14min all sailed through)
- drawdown monitor: 'profit > 5%' armed at +0.5% price, then market-closed
  winners on a 40% giveback (SNDK +0.6%/35min, NVDA +0.3%/37min) — every
  winner strangled at breakeven while losers realized fast

Fix: evaluate both guards on price-basis PnL (margin PnL% / leverage).
Throttle thresholds keep their documented price meaning at any leverage;
the drawdown monitor now arms only after a real +5% price move. Peak-PnL
cache stays margin-basis for prompt display consistency.

Replay simulator updated to the same price-basis semantics.
2026-07-24 13:39:40 +09:00
tinkle-community
ed3bebf287 feat: TPE Bayesian search harness for autopilot risk/throttle params
Replay recorded AI decisions from decision_records under alternative
risk-control and throttle parameters, then search the 15-dim space with
Optuna multivariate TPE.

- extract.py: parse decision cycles, intents (incl. throttled ones), 15m
  OHLCV and per-cycle prices out of input prompts into CSVs
- simulate.py: replay engine mirroring auto_trader_throttle.go semantics
  (margin-based PnL%% thresholds, intra-candle SL/TP trigger orders,
  opens/hour + reentry + margin gates); live-config replay reproduces the
  real account curve (-83%% sim vs -85%% actual)
- search.py: TPE search (train/holdout split) + baselines + importances

Findings: train-window optima do not survive holdout (overfit); only
2/800 trials are positive across all 3 time folds. Dominant lever is
min_confidence (importance 0.79) — the edge problem is decision quality,
not risk parameters.
2026-07-23 10:30:51 +09:00
tinkle-community
4881c27c44 config: raise leverage 5x -> 10x, double book to 2 x 5x notional
Operator choice to add aggression while keeping the hold-for-big-moves
design intact:
- Leverage 5x -> 10x: a wide -5% stop is now -50% of margin — still
  survivable, not an instant liquidation (that needed 20x).
- Per-position notional 2.5x -> 5x equity; 2 positions = 10x total
  account notional (full margin at 10x, ~10% liquidation cushion).
- Stops/targets, throttle, min-hold and noise-band settings unchanged:
  same wide (-5% / +10-12%) exits, doubled exposure.

Live strategy in data/data.db updated to match (lev=10, ratio=5.0).
2026-07-23 01:43:09 +09:00
tinkle-community
39eac5aca7 config: stop the churn — hold for big moves, wide TP/SL, low leverage
Live decomposition of the losing streak: 23% win rate with avg win +$1.23 /
avg loss -$1.04 on ~0.3-0.5% price moves, where the ~0.14% round-trip fee ate
30-50% of every tiny winner. Death by small-move grinding. The AI was closing
positions on ±0.5% noise after the 60m min-hold, capping winners at ~0.86%.

Redesign to 'few big positions, held for big moves':
- Throttle: min hold 60m->4h, noise-close window 90m->8h, reentry 30m->3h,
  opens/hour 30->3, opens/cycle 6->2. Noise band widened -1%..+2% -> -4%..+6%
  so small moves can no longer trigger a close.
- Exits: stop bypass -2.5% -> -5%, take-profit bypass +5% -> +12% (wide,
  asymmetric — let winners run, cut losers only on a real move).
- Leverage 20x -> 5x: a -5% stop at 20x is instant liquidation; at 5x it is
  -25% of margin, survivable. 2 positions x 2.5x = 5x total (full margin,
  ~20% cushion) instead of 4x5x=20x.
- Prompt now instructs the AI to set wide stops (~-5%) and distant targets
  (~+10-12%), hold multi-hour, and never scalp 0.5% moves.

Live strategy updated (maxPos=2, lev=5, ratio=2.5).
2026-07-21 15:02:23 +09:00
tinkle-community
0f3e71560c fix: fetch all Hyperliquid fills — UserFillsByTime is capped at 100
Root cause of the dashboard over-reporting profit while the account lost
money: the fill sync used UserFillsByTime, which hard-caps at 100 fills per
response. At 20x/4-position frequency the account does >100 fills/24h, so
~20% of fills were silently dropped — and the dropped ones skewed toward
losers, inflating recorded PnL and under-counting fees. Verified: over a 46h
window the DB showed net +$39 while Hyperliquid official was -$18.6, and the
equity drop ($176 -> $156) confirmed the loss.

Switch GetTrades to UserFills (returns up to 2000 recent fills), filtering to
startTime client-side, and widen the sync lookback 24h -> 7d so gaps
backfill. Verified live: a sync now pulls 675 fills where it previously
always received exactly 100.

Note: this stops future drift; already-corrupted historical position rows are
not retroactively rebuilt (dedup blocks re-processing). Account equity remains
the authoritative scoreboard.
2026-07-16 11:11:56 +09:00
tinkle-community
eabd279d10 feat: show hold duration in the recent trades panel
Adds a HOLD column (45m / 2h10 / 1d3h) plus a header row to the dashboard's
recent-closes table. Makes the hold-time-vs-PnL pattern (short holds bleed,
1h+ holds carry the edge) visible per trade instead of only in the aggregate
Edge Profile panel.
2026-07-15 19:36:58 +09:00
tinkle-community
09b7ac9e92 config: autopilot to 4x 5x notional at 20x leverage; short floor 0.75 -> 0.4
Two independent changes bundled:
- Position sizing back to 4 positions x 5x equity notional at 20x leverage
  (20x total account notional, ~5% liquidation cushion) — aggressive book
  size by operator choice, for bigger single positions on a small account.
- Forced short-coverage signal floor lowered 0.75 -> 0.4. At 0.75, a
  long-leaning board left every bearish candidate below the bar, so no short
  ever opened and the book became a one-directional long bet. 0.4 keeps
  genuine directional signals so the book actually hedges — which matters
  more, not less, at 20x.
2026-07-14 12:11:29 +09:00
tinkle-community
dc68884559 feat: autopilot book to 4 positions x 5x notional at 20x leverage
Was 2 positions x ~equity×1.2 — too few holdings and no room for shorts
(the 2 slots filled with the strongest signals, which were long-leaning, so
the balanced candidate pool never got expressed as short trades).

Now: max 4 positions, each sized at equity×5 notional, 20x leverage. Four
positions × 5x = 20x total account notional = full margin at 20x — the
operator's requested ceiling. This gives room for ~2 long + 2 short and
bigger single positions; the existing direction-balanced candidate selection
plus long/short coverage fills both sides when strong bearish signals exist.

Applied across all three config sources (default template, quick-create
preset, studio unified) with matching test assertions and 20x prompt copy.
2026-07-11 11:20:33 +09:00
33 changed files with 2043 additions and 285 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

@@ -0,0 +1,2 @@
.venv/
data/

180
scripts/optimize/extract.py Normal file
View 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
View 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()

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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