24 Commits

Author SHA1 Message Date
tinkle-community
472f8aaba9 feat: derive upto call cost from streamed token usage
The gateway cannot deliver the X-Claw402-Settled-Usd header on SSE
responses (headers are flushed before usage is known), so streamed
decision calls fell back to the flat catalog price. Capture the token
usage from the SSE stream and compute the settled cost with the
gateway's own formula (per-1M-token rates x 1.15 margin, $0.0001 floor)
so ai_charges reflects real spend on the streaming path too.
2026-07-28 19:51:40 +09:00
tinkle-community
861b4b0ea5 feat: show pay-as-you-go token pricing instead of flat per-call prices
Chat routes settle via x402 upto on actual usage, so the flat $/call
labels were misleading. Model cards now show official $/1M-token input
and output rates from the gateway pricing config; pay-per-call wording
updated to pay-as-you-go.
2026-07-28 19:46:01 +09:00
tinkle-community
ca5dcbf4be feat: record AI charges at gateway-settled cost under x402 upto
The claw402 gateway now settles chat routes pay-as-you-go (upto) and
reports the actual amount in the X-Claw402-Settled-Usd response header.
Capture it on all three x402 call paths (stream, simple, full), expose it
via Claw402Client.LastCallCostUSD, and prefer it over the flat catalog
estimate when recording ai_charges — cost stats now match real spend.
2026-07-28 19:32:10 +09:00
tinkle-community
7296b204e4 fix: echo server-offered x402 payment scheme instead of hardcoding exact
Under the upto scheme the signed amount is only an authorization cap and
the gateway settles on actual token usage; echoing accepts[0].scheme lets
upto routes settle pay-as-you-go instead of charging the full estimate.
2026-07-28 19:23:06 +09:00
tinkle-community
2f8c6111b1 feat: restrict AI models to claw402 gateway, default GPT-5.6 Sol
- Offer only claw402 (x402 USDC) models: remove direct-API providers
  (deepseek/qwen/openai/claude/gemini/grok/kimi/minimax) and BlockRun
  from supported models, defaults, and the config UI
- Sync model catalog with the claw402 catalog (GET /api/v1/catalog):
  GPT-5.6 Sol/Terra/Luna, Claude Fable 5 / Opus 4.8, DeepSeek-V4
  Flash/Pro, GLM-5 — official naming, brand icons, verified prices
- Default model gpt-5.6 (Sol) across backend and frontend
- fix: omit temperature for GPT-5/o-series reasoning models; they
  reject non-default values and fail the whole request
- fix: hot-reload running traders when a model config is saved under
  its provider key (previously required a restart to take effect)
- fix: allow saving model config edits without re-entering the stored
  key; backend preserves the existing key when api_key is empty
- fix: record AI charges with the effective model name so per-call
  cost tracking matches the invoked model and price
2026-07-28 18:16:43 +09:00
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
43 changed files with 2349 additions and 588 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

1
.gitignore vendored
View File

@@ -49,6 +49,7 @@ web/node_modules/
node_modules/ node_modules/
web/dist/ web/dist/
web/.vite/ web/.vite/
*.tsbuildinfo
# ESLint 临时报告文件(调试时生成,不纳入版本控制) # ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json eslint-*.json

View File

@@ -68,14 +68,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
if len(models) == 0 { if len(models) == 0 {
logger.Infof("⚠️ No AI models in database, returning defaults") logger.Infof("⚠️ No AI models in database, returning defaults")
defaultModels := []SafeModelConfig{ defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false}, {ID: "claw402", Name: "Claw402 (Base USDC)", Provider: "claw402", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
} }
c.JSON(http.StatusOK, defaultModels) c.JSON(http.StatusOK, defaultModels)
return return
@@ -116,14 +109,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
if len(safeModels) == 0 { if len(safeModels) == 0 {
logger.Infof("⚠️ No visible AI models in database, returning defaults") logger.Infof("⚠️ No visible AI models in database, returning defaults")
defaultModels := []SafeModelConfig{ defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false}, {ID: "claw402", Name: "Claw402 (Base USDC)", Provider: "claw402", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
} }
c.JSON(http.StatusOK, defaultModels) c.JSON(http.StatusOK, defaultModels)
return return
@@ -192,7 +178,24 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID) logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
} }
// Update each model's configuration and track traders that need reload // Update each model's configuration and track traders that need reload.
// The request key may be either the model row id or the provider name
// (legacy clients send the provider, e.g. "claw402", while trader rows
// reference the full model id) — resolve both, mirroring the matching in
// AIModelStore.Update, otherwise running traders keep the old model.
modelIDCandidates := func(modelID string) map[string]bool {
candidates := map[string]bool{modelID: true}
if models, listErr := s.store.AIModel().List(userID); listErr == nil {
for _, m := range models {
if m.ID == modelID || m.Provider == modelID {
candidates[m.ID] = true
candidates[m.Provider] = true
}
}
}
return candidates
}
tradersToReload := make(map[string]bool) tradersToReload := make(map[string]bool)
for modelID, modelData := range req.Models { for modelID, modelData := range req.Models {
// SSRF protection: validate custom_api_url before storing // SSRF protection: validate custom_api_url before storing
@@ -206,9 +209,11 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
} }
// Find traders using this AI model BEFORE updating // Find traders using this AI model BEFORE updating
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID) for candidateID := range modelIDCandidates(modelID) {
for _, t := range traders { traders, _ := s.store.Trader().ListByAIModelID(userID, candidateID)
tradersToReload[t.ID] = true for _, t := range traders {
tradersToReload[t.ID] = true
}
} }
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName) err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
@@ -239,17 +244,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
func (s *Server) handleGetSupportedModels(c *gin.Context) { func (s *Server) handleGetSupportedModels(c *gin.Context) {
// Return static list of supported AI models with default versions // Return static list of supported AI models with default versions
supportedModels := []map[string]interface{}{ supportedModels := []map[string]interface{}{
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"}, {"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "gpt-5.6"},
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
} }
c.JSON(http.StatusOK, supportedModels) c.JSON(http.StatusOK, supportedModels)

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 == "" {

View File

@@ -78,6 +78,17 @@ type Client struct {
Log Logger // Exported for sub-packages Log Logger // Exported for sub-packages
Cfg *Config // Exported for sub-packages Cfg *Config // Exported for sub-packages
// LastCallSettledUSD is the actually-settled cost (USD) of the most
// recent call, reported by x402 upto gateways via response header.
// Zero when the last call carried no settlement information.
LastCallSettledUSD float64
// LastCallUsage is the token usage of the most recent streamed call.
// On SSE responses the gateway cannot deliver the settlement header
// (headers are flushed before usage is known), so callers derive the
// actual cost from this instead. Nil when the stream carried no usage.
LastCallUsage *TokenUsage
// Hooks are used to implement dynamic dispatch (polymorphism) // Hooks are used to implement dynamic dispatch (polymorphism)
// When provider.DeepSeekClient embeds Client, Hooks point to DeepSeekClient // When provider.DeepSeekClient embeds Client, Hooks point to DeepSeekClient
// This way methods called in Call() are automatically dispatched to the overridden version // This way methods called in Call() are automatically dispatched to the overridden version
@@ -213,6 +224,18 @@ func (client *Client) SetAuthHeader(reqHeader http.Header) {
reqHeader.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey)) reqHeader.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey))
} }
// modelSupportsCustomTemperature reports whether the target model accepts a
// non-default temperature. OpenAI reasoning models (gpt-5 family, o-series)
// reject any value other than the default and fail the whole request with
// "unsupported_value", so temperature must be omitted for them.
func modelSupportsCustomTemperature(model string) bool {
m := strings.ToLower(model)
return !strings.HasPrefix(m, "gpt-5") &&
!strings.HasPrefix(m, "o1") &&
!strings.HasPrefix(m, "o3") &&
!strings.HasPrefix(m, "o4")
}
func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any { func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
// Build messages array // Build messages array
messages := []map[string]string{} messages := []map[string]string{}
@@ -242,9 +265,11 @@ func (client *Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[s
// Build request body // Build request body
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"model": client.Model, "model": client.Model,
"messages": messages, "messages": messages,
"temperature": client.Cfg.Temperature, // Use configured temperature }
if modelSupportsCustomTemperature(client.Model) {
requestBody["temperature"] = client.Cfg.Temperature
} }
// OpenAI newer models use max_completion_tokens instead of max_tokens // OpenAI newer models use max_completion_tokens instead of max_tokens
if client.Provider == ProviderOpenAI { if client.Provider == ProviderOpenAI {
@@ -655,11 +680,13 @@ func (client *Client) BuildRequestBodyFromRequest(req *Request) map[string]any {
} }
// Add optional parameters (only add non-nil parameters) // Add optional parameters (only add non-nil parameters)
if req.Temperature != nil { if modelSupportsCustomTemperature(req.Model) {
requestBody["temperature"] = *req.Temperature if req.Temperature != nil {
} else { requestBody["temperature"] = *req.Temperature
// If not set in Request, use Client's configuration } else {
requestBody["temperature"] = client.Cfg.Temperature // If not set in Request, use Client's configuration
requestBody["temperature"] = client.Cfg.Temperature
}
} }
// OpenAI newer models use max_completion_tokens instead of max_tokens // OpenAI newer models use max_completion_tokens instead of max_tokens

View File

@@ -50,37 +50,26 @@ func shortAddr(addr string) string {
const ( const (
DefaultClaw402URL = "https://claw402.ai" DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402Model = "deepseek-v4-flash" DefaultClaw402Model = "gpt-5.6"
) )
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths. // claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
// Must stay in sync with the claw402 catalog (GET /api/v1/catalog).
var claw402ModelEndpoints = map[string]string{ var claw402ModelEndpoints = map[string]string{
// OpenAI // OpenAI
"gpt-5.4": "/api/v1/ai/openai/chat/5.4", "gpt-5.6": "/api/v1/ai/openai/chat/5.6",
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro", "gpt-5.6-terra": "/api/v1/ai/openai/chat/5.6-terra",
"gpt-5.3": "/api/v1/ai/openai/chat/5.3", "gpt-5.6-luna": "/api/v1/ai/openai/chat/5.6-luna",
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
// Anthropic // Anthropic
"claude-opus": "/api/v1/ai/anthropic/messages/opus", "claude-fable": "/api/v1/ai/anthropic/messages/fable",
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
// DeepSeek // DeepSeek
"deepseek": "/api/v1/ai/deepseek/chat", "deepseek": "/api/v1/ai/deepseek/chat",
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner", "deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
"deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash", "deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash",
"deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro", "deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro",
// Qwen
"qwen-max": "/api/v1/ai/qwen/chat/max",
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
// Grok
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
// Gemini
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
// Kimi
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
// Z.AI (Zhipu) // Z.AI (Zhipu)
"glm-5": "/api/v1/ai/zhipu/chat", "glm-5": "/api/v1/ai/zhipu/chat",
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
} }
func init() { func init() {
@@ -100,6 +89,24 @@ type Claw402Client struct {
func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client } func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client }
// LastCallCostUSD reports the actually-settled cost of the most recent call:
// the gateway's X-Claw402-Settled-Usd header when available (non-streaming),
// otherwise derived from streamed token usage via the gateway's own formula —
// on SSE responses the header cannot be delivered because HTTP headers are
// flushed before usage is known. ok is false when neither source is available;
// callers should then fall back to the flat catalog price.
func (c *Claw402Client) LastCallCostUSD() (float64, bool) {
if c.Client.LastCallSettledUSD > 0 {
return c.Client.LastCallSettledUSD, true
}
if u := c.Client.LastCallUsage; u != nil && u.PromptTokens+u.CompletionTokens > 0 {
if cost, ok := store.ComputeUsageCost(c.Model, u.PromptTokens, u.CompletionTokens); ok {
return cost, true
}
}
return 0, false
}
// NewClaw402Client creates a claw402 client (backward compatible). // NewClaw402Client creates a claw402 client (backward compatible).
func NewClaw402Client() mcp.AIClient { func NewClaw402Client() mcp.AIClient {
return NewClaw402ClientWithOptions() return NewClaw402ClientWithOptions()

View File

@@ -12,6 +12,7 @@ import (
"io" "io"
"math/big" "math/big"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -201,7 +202,35 @@ func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string
return SignX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource) return SignX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
} }
// X402SettledUSDHeader carries the actually-settled cost (USD) of an upto
// call, set by the claw402 gateway on the paid response.
const X402SettledUSDHeader = "X-Claw402-Settled-Usd"
func storeRespHeader(sink []*http.Header, h http.Header) {
for _, p := range sink {
if p != nil {
*p = h
}
}
}
// captureSettledUSD records the gateway-reported settled cost on the client.
// Zero when the response carried no settlement header (e.g. exact-scheme
// routes, where the flat catalog price applies instead).
func captureSettledUSD(c *mcp.Client, h http.Header) {
c.LastCallSettledUSD = 0
if h == nil {
return
}
if v := h.Get(X402SettledUSDHeader); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
c.LastCallSettledUSD = f
}
}
}
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow. // DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
// An optional headerSink receives the headers of the successful response.
func DoX402Request( func DoX402Request(
ctx context.Context, ctx context.Context,
httpClient *http.Client, httpClient *http.Client,
@@ -209,6 +238,7 @@ func DoX402Request(
signFn X402SignFunc, signFn X402SignFunc,
providerTag string, providerTag string,
logger mcp.Logger, logger mcp.Logger,
headerSink ...*http.Header,
) ([]byte, error) { ) ([]byte, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
@@ -277,6 +307,7 @@ func DoX402Request(
if attempt > 1 { if attempt > 1 {
logger.Infof("✅ [%s] Payment retry succeeded on attempt %d", providerTag, attempt) logger.Infof("✅ [%s] Payment retry succeeded on attempt %d", providerTag, attempt)
} }
storeRespHeader(headerSink, resp2.Header)
return body2, nil return body2, nil
} }
@@ -333,6 +364,7 @@ func DoX402Request(
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body)) return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
} }
storeRespHeader(headerSink, resp.Header)
return body, nil return body, nil
} }
@@ -494,6 +526,8 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
c.LastCallSettledUSD = 0
c.LastCallUsage = nil
resp, err := DoX402RequestStream(ctx, c.HTTPClient, func() (*http.Request, error) { resp, err := DoX402RequestStream(ctx, c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData) return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log) }, signFn, tag, c.Log)
@@ -501,6 +535,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
return "", err return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
captureSettledUSD(c, resp.Header)
ct := resp.Header.Get("Content-Type") ct := resp.Header.Get("Content-Type")
c.Log.Infof("📡 [%s] Response Content-Type: %s", tag, ct) c.Log.Infof("📡 [%s] Response Content-Type: %s", tag, ct)
@@ -544,6 +579,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine) text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
mcp.ReportStreamUsage(usage, c.Provider, c.Model) mcp.ReportStreamUsage(usage, c.Provider, c.Model)
c.LastCallUsage = usage
if text != "" { if text != "" {
c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text)) c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text))
@@ -590,12 +626,15 @@ func X402Call(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt, user
return "", err return "", err
} }
c.LastCallSettledUSD = 0
var respHeader http.Header
body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) { body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData) return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log) }, signFn, tag, c.Log, &respHeader)
if err != nil { if err != nil {
return "", err return "", err
} }
captureSettledUSD(c, respHeader)
return c.Hooks.ParseMCPResponse(body) return c.Hooks.ParseMCPResponse(body)
} }
@@ -616,12 +655,15 @@ func X402CallFull(c *mcp.Client, signFn X402SignFunc, tag string, req *mcp.Reque
return nil, err return nil, err
} }
c.LastCallSettledUSD = 0
var respHeader http.Header
body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) { body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData) return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log) }, signFn, tag, c.Log, &respHeader)
if err != nil { if err != nil {
return nil, err return nil, err
} }
captureSettledUSD(c, respHeader)
return c.Hooks.ParseMCPResponseFull(body) return c.Hooks.ParseMCPResponseFull(body)
} }
@@ -664,6 +706,13 @@ func SignX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt X402Ac
if maxTimeout == 0 { if maxTimeout == 0 {
maxTimeout = 300 maxTimeout = 300
} }
// Echo the scheme offered by the server ("exact" or "upto") instead of
// hardcoding — under "upto" the signed amount is a cap and the gateway
// settles on actual usage.
scheme := opt.Scheme
if scheme == "" {
scheme = "exact"
}
resourceURL := "" resourceURL := ""
resourceDesc := "" resourceDesc := ""
@@ -734,7 +783,7 @@ func SignX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt X402Ac
"mimeType": resourceMime, "mimeType": resourceMime,
}, },
"accepted": map[string]interface{}{ "accepted": map[string]interface{}{
"scheme": "exact", "scheme": scheme,
"network": network, "network": network,
"amount": amount, "amount": amount,
"asset": asset, "asset": asset,

2
scripts/optimize/.gitignore vendored Normal file
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

@@ -24,18 +24,12 @@ var modelPrices = map[string]float64{
"deepseek-reasoner": 0.005, "deepseek-reasoner": 0.005,
"deepseek-v4-flash": 0.003, "deepseek-v4-flash": 0.003,
"deepseek-v4-pro": 0.01, "deepseek-v4-pro": 0.01,
"gpt-5.4": 0.05, "gpt-5.6": 0.06,
"gpt-5.4-pro": 0.50, "gpt-5.6-terra": 0.03,
"gpt-5.3": 0.01, "gpt-5.6-luna": 0.012,
"gpt-5-mini": 0.005, "claude-fable": 0.24,
"claude-opus": 0.12, "claude-opus": 0.12,
"qwen-max": 0.01, "glm-5": 0.003,
"qwen-plus": 0.005,
"qwen-turbo": 0.002,
"qwen-flash": 0.002,
"grok-4.1": 0.06,
"gemini-3.1-pro": 0.03,
"kimi-k2.5": 0.008,
} }
// GetModelPrice returns the price per call for a given model // GetModelPrice returns the price per call for a given model
@@ -46,6 +40,45 @@ func GetModelPrice(model string) float64 {
return 0.01 // default fallback return 0.01 // default fallback
} }
// modelTokenPrices maps model → USD per 1M input/output tokens, mirroring the
// claw402 gateway pricing config (providers/*.yaml). Used to derive the actual
// upto-settled cost from streamed token usage, where the gateway cannot
// deliver the settlement header (SSE headers are flushed before usage is known).
var modelTokenPrices = map[string]struct{ In, Out float64 }{
"gpt-5.6": {5, 30},
"gpt-5.6-terra": {2.5, 15},
"gpt-5.6-luna": {1, 6},
"claude-fable": {10, 50},
"claude-opus": {5, 25},
"deepseek-v4-flash": {0.14, 0.28},
"deepseek-v4-pro": {1.74, 3.48},
"deepseek": {0.27, 1.1},
"deepseek-reasoner": {0.55, 2.19},
"glm-5": {0.6, 2},
}
// Gateway upto settlement formula constants (see claw402 token_estimate
// pricing: token_safety_margin 0.15, token_min_price 0.0001).
const (
uptoSafetyMargin = 1.15
uptoMinPriceUSD = 0.0001
)
// ComputeUsageCost derives the upto-settled cost of a call from token usage,
// using the same formula as the claw402 gateway. ok is false for models
// without a token price entry.
func ComputeUsageCost(model string, promptTokens, completionTokens int) (float64, bool) {
p, ok := modelTokenPrices[model]
if !ok {
return 0, false
}
cost := (float64(promptTokens)*p.In + float64(completionTokens)*p.Out) / 1e6 * uptoSafetyMargin
if cost < uptoMinPriceUSD {
cost = uptoMinPriceUSD
}
return cost, true
}
// AIChargeStore handles AI charge records // AIChargeStore handles AI charge records
type AIChargeStore struct { type AIChargeStore struct {
db *gorm.DB db *gorm.DB
@@ -62,12 +95,18 @@ func (s *AIChargeStore) initTables() error {
// Record records a new AI charge // Record records a new AI charge
func (s *AIChargeStore) Record(traderID, model, provider string) error { func (s *AIChargeStore) Record(traderID, model, provider string) error {
cost := GetModelPrice(model) return s.RecordWithCost(traderID, model, provider, GetModelPrice(model))
}
// RecordWithCost records a charge with an explicit cost — e.g. the actual
// settled amount reported by the payment gateway (upto scheme) — instead of
// the flat per-call estimate from modelPrices.
func (s *AIChargeStore) RecordWithCost(traderID, model, provider string, costUSD float64) error {
charge := &AICharge{ charge := &AICharge{
TraderID: traderID, TraderID: traderID,
Model: model, Model: model,
Provider: provider, Provider: provider,
CostUSD: cost, CostUSD: costUSD,
} }
return s.db.Create(charge).Error return s.db.Create(charge).Error
} }

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

@@ -131,9 +131,28 @@ func (at *AutoTrader) runCycle() error {
} }
} }
// Record AI charge (track cost regardless of decision outcome) // Record AI charge (track cost regardless of decision outcome).
// Use the effective model name (custom model, e.g. "gpt-5.6") so the
// per-call price lookup matches what was actually invoked — at.aiModel is
// the provider id (e.g. "claw402") and would fall back to the default price.
// Prefer the gateway-reported settled amount (upto scheme) over the flat
// catalog estimate when the client exposes it.
if aiDecision != nil && at.store != nil { if aiDecision != nil && at.store != nil {
if chargeErr := at.store.AICharge().Record(at.id, at.aiModel, at.config.AIModel); chargeErr != nil { chargeModel := at.config.CustomModelName
if chargeModel == "" {
chargeModel = at.aiModel
}
var chargeErr error
if r, ok := at.mcpClient.(interface{ LastCallCostUSD() (float64, bool) }); ok {
if actual, has := r.LastCallCostUSD(); has {
chargeErr = at.store.AICharge().RecordWithCost(at.id, chargeModel, at.config.AIModel, actual)
} else {
chargeErr = at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel)
}
} else {
chargeErr = at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel)
}
if chargeErr != nil {
at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr) at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr)
} }
} }

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

@@ -15,6 +15,7 @@ const MODEL_COLORS: Record<string, string> = {
openai: '#10A37F', openai: '#10A37F',
minimax: '#E45735', minimax: '#E45735',
claw402: '#7C3AED', claw402: '#7C3AED',
zhipu: '#3859F3',
} }
// Returns the icon for an AI model // Returns the icon for an AI model

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

@@ -4,12 +4,12 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types' import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations' import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations' import { t } from '../../i18n/translations'
import { getModelIcon } from '../common/ModelIcons' import { getModelIcon, getModelColor } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator' import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard' import { ModelCard } from './ModelCard'
import { import {
BLOCKRUN_MODELS,
CLAW402_MODELS, CLAW402_MODELS,
DEFAULT_CLAW402_MODEL,
AI_PROVIDER_CONFIG, AI_PROVIDER_CONFIG,
getShortName, getShortName,
} from './model-constants' } from './model-constants'
@@ -50,11 +50,18 @@ export function ModelConfigModal({
const [baseUrl, setBaseUrl] = useState('') const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('') const [modelName, setModelName] = useState('')
// Always prefer allModels (supportedModels) for provider/id lookup; // The configured entry carries the saved details (wallet address, custom
// fall back to configuredModels for edit mode details (apiKey etc.) // model name, has_api_key); the template from supportedModels only describes
const selectedModel = // the provider. When editing, the configured entry must win — both can share
allModels?.find((m) => m.id === selectedModelId) || // the same id (e.g. "claw402").
configuredModels?.find((m) => m.id === selectedModelId) const configuredModel = configuredModels?.find((m) => m.id === selectedModelId)
const templateModel = allModels?.find((m) => m.id === selectedModelId)
const selectedModel = editingModelId
? configuredModel || templateModel
: templateModel || configuredModel
const hasExistingKey = Boolean(
configuredModel?.has_api_key || configuredModel?.apiKey
)
useEffect(() => { useEffect(() => {
if (editingModelId && selectedModel) { if (editingModelId && selectedModel) {
@@ -80,7 +87,10 @@ export function ModelConfigModal({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!selectedModelId || !apiKey.trim()) return if (!selectedModelId) return
// Editing with a stored key: an empty key means "keep the existing one"
// (the backend preserves the stored key when api_key is empty).
if (!apiKey.trim() && !(editingModelId && hasExistingKey)) return
onSave( onSave(
selectedModelId, selectedModelId,
apiKey.trim(), apiKey.trim(),
@@ -189,6 +199,7 @@ export function ModelConfigModal({
apiKey={apiKey} apiKey={apiKey}
modelName={modelName} modelName={modelName}
editingModelId={editingModelId} editingModelId={editingModelId}
hasExistingKey={hasExistingKey}
initialWalletAddress={selectedModel.walletAddress} initialWalletAddress={selectedModel.walletAddress}
initialBalanceUsdc={selectedModel.balanceUsdc} initialBalanceUsdc={selectedModel.balanceUsdc}
onApiKeyChange={setApiKey} onApiKeyChange={setApiKey}
@@ -323,7 +334,7 @@ function ModelSelectionStep({
border: '1px solid rgba(46, 139, 87, 0.2)', border: '1px solid rgba(46, 139, 87, 0.2)',
}} }}
> >
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi GPT · Claude · DeepSeek · GLM
</span> </span>
</div> </div>
</button> </button>
@@ -345,39 +356,6 @@ function ModelSelectionStep({
/> />
))} ))}
</div> </div>
{availableModels.some((m) => m.provider?.startsWith('blockrun')) && (
<>
<div className="flex items-center gap-3 pt-2">
<div
className="flex-1 h-px"
style={{ background: 'rgba(26,24,19,0.14)' }}
/>
<span
className="text-xs font-medium px-2"
style={{ color: '#8A8478' }}
>
{t('modelConfig.viaBlockrunWallet', language)}
</span>
<div
className="flex-1 h-px"
style={{ background: 'rgba(26,24,19,0.14)' }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
{availableModels
.filter((m) => m.provider?.startsWith('blockrun'))
.map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
</>
)}
<div className="text-xs text-center pt-2" style={{ color: '#8A8478' }}> <div className="text-xs text-center pt-2" style={{ color: '#8A8478' }}>
{t('modelConfig.modelsConfigured', language)} {t('modelConfig.modelsConfigured', language)}
</div> </div>
@@ -389,6 +367,7 @@ function Claw402ConfigForm({
apiKey, apiKey,
modelName, modelName,
editingModelId, editingModelId,
hasExistingKey,
initialWalletAddress, initialWalletAddress,
initialBalanceUsdc, initialBalanceUsdc,
onApiKeyChange, onApiKeyChange,
@@ -400,6 +379,7 @@ function Claw402ConfigForm({
apiKey: string apiKey: string
modelName: string modelName: string
editingModelId: string | null editingModelId: string | null
hasExistingKey?: boolean
initialWalletAddress?: string initialWalletAddress?: string
initialBalanceUsdc?: string initialBalanceUsdc?: string
onApiKeyChange: (value: string) => void onApiKeyChange: (value: string) => void
@@ -442,6 +422,11 @@ function Claw402ConfigForm({
apiKey.startsWith('0x') && apiKey.startsWith('0x') &&
/^0x[0-9a-fA-F]{64}$/.test(apiKey) /^0x[0-9a-fA-F]{64}$/.test(apiKey)
// Editing with a stored key: allow saving (e.g. switching model) without
// re-entering the private key, as long as the field is left blank.
const canSubmit =
isKeyValid || (Boolean(editingModelId) && Boolean(hasExistingKey) && !apiKey)
// Truncate address for display // Truncate address for display
// Debounced validation when apiKey changes // Debounced validation when apiKey changes
@@ -561,7 +546,7 @@ function Claw402ConfigForm({
{t('modelConfig.allModelsClaw', language)} {t('modelConfig.allModelsClaw', language)}
</div> </div>
<div className="flex items-center justify-center gap-3 mt-3 flex-wrap"> <div className="flex items-center justify-center gap-3 mt-3 flex-wrap">
{['GPT', 'Claude', 'DeepSeek', 'Gemini', 'Grok', 'Qwen', 'Kimi'].map( {['GPT', 'Claude', 'DeepSeek', 'GLM'].map(
(name) => ( (name) => (
<span <span
key={name} key={name}
@@ -592,7 +577,7 @@ function Claw402ConfigForm({
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CLAW402_MODELS.map((m) => { {CLAW402_MODELS.map((m) => {
const isSelected = (modelName || 'deepseek') === m.id const isSelected = (modelName || DEFAULT_CLAW402_MODEL) === m.id
return ( return (
<button <button
key={m.id} key={m.id}
@@ -608,7 +593,22 @@ function Claw402ConfigForm({
: '1px solid rgba(26,24,19,0.14)', : '1px solid rgba(26,24,19,0.14)',
}} }}
> >
<span className="text-base mt-0.5">{m.icon}</span> <div
className="w-7 h-7 mt-0.5 rounded-lg flex items-center justify-center shrink-0"
style={{
background: '#fff',
border: '1px solid rgba(26,24,19,0.10)',
}}
>
{getModelIcon(m.brand, { width: 18, height: 18 }) || (
<span
className="text-xs font-bold"
style={{ color: getModelColor(m.brand) }}
>
{m.provider[0]}
</span>
)}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<div <div
@@ -636,8 +636,11 @@ function Claw402ConfigForm({
> >
{m.provider} · {m.desc} {m.provider} · {m.desc}
</div> </div>
<div className="text-[10px]" style={{ color: '#2E8B57' }}> <div
~${m.price}/call className="text-[10px] font-medium"
style={{ color: '#2E8B57' }}
>
${m.priceIn} in · ${m.priceOut} out /1M tok
</div> </div>
</div> </div>
{isSelected && ( {isSelected && (
@@ -1134,11 +1137,11 @@ function Claw402ConfigForm({
</button> </button>
<button <button
type="submit" type="submit"
disabled={!isKeyValid} disabled={!canSubmit}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ style={{
background: isKeyValid ? '#E0483B' : '#E8E2D5', background: canSubmit ? '#E0483B' : '#E8E2D5',
color: isKeyValid ? '#fff' : '#8A8478', color: canSubmit ? '#fff' : '#8A8478',
}} }}
> >
{'🚀 ' + t('modelConfig.startTrading', language)} {'🚀 ' + t('modelConfig.startTrading', language)}
@@ -1216,9 +1219,7 @@ function StandardProviderConfigForm({
> >
<ExternalLink className="w-4 h-4" style={{ color: '#E0483B' }} /> <ExternalLink className="w-4 h-4" style={{ color: '#E0483B' }} />
<span className="text-sm font-medium" style={{ color: '#E0483B' }}> <span className="text-sm font-medium" style={{ color: '#E0483B' }}>
{selectedModel.provider?.startsWith('blockrun') {t('modelConfig.getApiKey', language)}
? t('modelConfig.getStarted', language)
: t('modelConfig.getApiKey', language)}
</span> </span>
</a> </a>
)} )}
@@ -1276,9 +1277,7 @@ function StandardProviderConfigForm({
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/> />
</svg> </svg>
{selectedModel.provider?.startsWith('blockrun') {'API Key *'}
? t('modelConfig.walletPrivateKeyLabel', language)
: 'API Key *'}
</label> </label>
<input <input
type="password" type="password"
@@ -1287,11 +1286,7 @@ function StandardProviderConfigForm({
placeholder={ placeholder={
editingModelId && selectedModel.has_api_key editingModelId && selectedModel.has_api_key
? 'Saved. Re-enter to replace.' ? 'Saved. Re-enter to replace.'
: selectedModel.provider === 'blockrun-base' : t('enterAPIKey', language)
? '0x... (EVM private key)'
: selectedModel.provider === 'blockrun-sol'
? 'bs58 encoded key (Solana)'
: t('enterAPIKey', language)
} }
className="w-full px-4 py-3 rounded-xl" className="w-full px-4 py-3 rounded-xl"
style={{ style={{
@@ -1299,13 +1294,12 @@ function StandardProviderConfigForm({
border: '1px solid rgba(26,24,19,0.14)', border: '1px solid rgba(26,24,19,0.14)',
color: '#1A1813', color: '#1A1813',
}} }}
required required={!(editingModelId && selectedModel.has_api_key)}
/> />
</div> </div>
{/* Custom Base URL (hidden for BlockRun) */} {/* Custom Base URL */}
{!selectedModel.provider?.startsWith('blockrun') && ( <div className="space-y-2">
<div className="space-y-2">
<label <label
className="flex items-center gap-2 text-sm font-semibold" className="flex items-center gap-2 text-sm font-semibold"
style={{ color: '#1A1813' }} style={{ color: '#1A1813' }}
@@ -1338,15 +1332,13 @@ function StandardProviderConfigForm({
color: '#1A1813', color: '#1A1813',
}} }}
/> />
<div className="text-xs" style={{ color: '#8A8478' }}> <div className="text-xs" style={{ color: '#8A8478' }}>
{t('leaveBlankForDefault', language)} {t('leaveBlankForDefault', language)}
</div>
</div> </div>
)} </div>
{/* Custom Model Name (hidden for BlockRun) */} {/* Custom Model Name */}
{!selectedModel.provider?.startsWith('blockrun') && ( <div className="space-y-2">
<div className="space-y-2">
<label <label
className="flex items-center gap-2 text-sm font-semibold" className="flex items-center gap-2 text-sm font-semibold"
style={{ color: '#1A1813' }} style={{ color: '#1A1813' }}
@@ -1379,68 +1371,10 @@ function StandardProviderConfigForm({
color: '#1A1813', color: '#1A1813',
}} }}
/> />
<div className="text-xs" style={{ color: '#8A8478' }}> <div className="text-xs" style={{ color: '#8A8478' }}>
{t('leaveBlankForDefaultModel', language)} {t('leaveBlankForDefaultModel', language)}
</div>
</div> </div>
)} </div>
{/* BlockRun Model Selector */}
{selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label
className="flex items-center gap-2 text-sm font-semibold"
style={{ color: '#1A1813' }}
>
<svg
className="w-4 h-4"
style={{ color: '#E0483B' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
{t('modelConfig.selectModelLabel', language)}
</label>
<div className="grid grid-cols-2 gap-2">
{BLOCKRUN_MODELS.map((m) => {
const isSelected = (modelName || BLOCKRUN_MODELS[0].id) === m.id
return (
<button
key={m.id}
type="button"
onClick={() => onModelNameChange(m.id)}
className="flex flex-col items-start px-3 py-2 rounded-xl text-left transition-all"
style={{
background: isSelected
? 'rgba(224, 72, 59, 0.12)'
: '#F1ECE2',
border: isSelected
? '1px solid #E0483B'
: '1px solid rgba(26,24,19,0.14)',
}}
>
<span
className="text-xs font-semibold"
style={{ color: isSelected ? '#E0483B' : '#1A1813' }}
>
{m.name}
</span>
<span className="text-[10px]" style={{ color: '#8A8478' }}>
{m.desc}
</span>
</button>
)
})}
</div>
</div>
)}
{/* Info Box */} {/* Info Box */}
<div <div
@@ -1478,7 +1412,11 @@ function StandardProviderConfigForm({
</button> </button>
<button <button
type="submit" type="submit"
disabled={!selectedModel || !apiKey.trim()} disabled={
!selectedModel ||
(!apiKey.trim() &&
!(editingModelId && selectedModel.has_api_key))
}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#E0483B', color: '#fff' }} style={{ background: '#E0483B', color: '#fff' }}
> >

View File

@@ -5,8 +5,9 @@ export interface Claw402Model {
name: string name: string
provider: string provider: string
desc: string desc: string
icon: string brand: string // key for getModelIcon / getModelColor
price: number // USD per call priceIn: number // USD per 1M input tokens (upto pay-as-you-go)
priceOut: number // USD per 1M output tokens
isNew?: boolean isNew?: boolean
} }
@@ -16,12 +17,6 @@ export interface AIProviderConfig {
apiName: string apiName: string
} }
export interface BlockrunModel {
id: string
name: string
desc: string
}
// Get friendly AI model display name // Get friendly AI model display name
export function getModelDisplayName(modelId: string): string { export function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) { switch (modelId.toLowerCase()) {
@@ -42,93 +37,25 @@ export function getShortName(fullName: string): string {
return parts.length > 1 ? parts[parts.length - 1] : fullName return parts.length > 1 ? parts[parts.length - 1] : fullName
} }
export const DEFAULT_CLAW402_MODEL = 'deepseek-v4-flash' export const DEFAULT_CLAW402_MODEL = 'gpt-5.6'
// Models available through Claw402 (x402 USDC payment protocol) // Models available through Claw402 (x402 USDC payment protocol)
// Must stay in sync with the claw402 catalog (GET /api/v1/catalog)
// Prices are USD per 1M tokens (input / output), settled pay-as-you-go via
// the x402 upto scheme — each call is charged on actual token usage.
export const CLAW402_MODELS: Claw402Model[] = [ export const CLAW402_MODELS: Claw402Model[] = [
{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'DeepSeek', desc: '$0.003/call', icon: '', price: 0.003, isNew: true }, { id: 'gpt-5.6', name: 'GPT-5.6 Sol', provider: 'OpenAI', desc: 'Flagship', brand: 'openai', priceIn: 5, priceOut: 30 },
{ id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', provider: 'DeepSeek', desc: '$0.01/call', icon: '🧠', price: 0.01, isNew: true }, { id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', provider: 'OpenAI', desc: 'Balanced', brand: 'openai', priceIn: 2.5, priceOut: 15 },
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: '$0.003/call', icon: '🔥', price: 0.003 }, { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', provider: 'OpenAI', desc: 'Cost-efficient', brand: 'openai', priceIn: 1, priceOut: 6 },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: '$0.005/call', icon: '🤔', price: 0.005 }, { id: 'claude-fable', name: 'Claude Fable 5', provider: 'Anthropic', desc: 'Most capable', brand: 'claude', priceIn: 10, priceOut: 50 },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: '$0.005/call', icon: '🚀', price: 0.005 }, { id: 'claude-opus', name: 'Claude Opus 4.8', provider: 'Anthropic', desc: 'Coding & agents flagship', brand: 'claude', priceIn: 5, priceOut: 25 },
{ id: 'qwen-turbo', name: 'Qwen Turbo', provider: 'Alibaba', desc: '$0.002/call', icon: '', price: 0.002 }, { id: 'deepseek-v4-flash', name: 'DeepSeek-V4 Flash', provider: 'DeepSeek', desc: 'Fast general model', brand: 'deepseek', priceIn: 0.14, priceOut: 0.28 },
{ id: 'qwen-flash', name: 'Qwen Flash', provider: 'Alibaba', desc: '$0.002/call', icon: '', price: 0.002 }, { id: 'deepseek-v4-pro', name: 'DeepSeek-V4 Pro', provider: 'DeepSeek', desc: 'Advanced reasoning', brand: 'deepseek', priceIn: 1.74, priceOut: 3.48 },
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: '$0.005/call', icon: '', price: 0.005 }, { id: 'glm-5', name: 'GLM-5', provider: 'Z.ai', desc: 'Deep reasoning flagship', brand: 'zhipu', priceIn: 0.6, priceOut: 2 },
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: '$0.008/call', icon: '🌙', price: 0.008 },
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: '$0.01/call', icon: '💡', price: 0.01 },
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: '$0.01/call', icon: '🌟', price: 0.01 },
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: '$0.03/call', icon: '💎', price: 0.03 },
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: '$0.05/call', icon: '⚡', price: 0.05 },
{ id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: '$0.06/call', icon: '⚡', price: 0.06 },
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: '$0.12/call', icon: '🎯', price: 0.12 },
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: '$0.50/call', icon: '🧠', price: 0.50 },
]
export const BLOCKRUN_MODELS: BlockrunModel[] = [
{
id: 'gpt-5.2',
name: 'GPT-5.2',
desc: 'Base wallet payment',
},
{
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
desc: 'Base wallet payment',
},
{
id: 'gemini-3.1-pro',
name: 'Gemini 3.1 Pro',
desc: 'Base wallet payment',
},
{
id: 'qwen3-max',
name: 'Qwen 3 Max',
desc: 'Base wallet payment',
},
] ]
// AI Provider configuration - default models and API links // AI Provider configuration - default models and API links
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = { export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
deepseek: {
defaultModel: 'deepseek-chat',
apiUrl: 'https://platform.deepseek.com/api_keys',
apiName: 'DeepSeek',
},
qwen: {
defaultModel: 'qwen3-max',
apiUrl: 'https://dashscope.console.aliyun.com/apiKey',
apiName: 'Alibaba Cloud',
},
openai: {
defaultModel: 'gpt-5.2',
apiUrl: 'https://platform.openai.com/api-keys',
apiName: 'OpenAI',
},
claude: {
defaultModel: 'claude-opus-4-6',
apiUrl: 'https://console.anthropic.com/settings/keys',
apiName: 'Anthropic',
},
gemini: {
defaultModel: 'gemini-3-pro-preview',
apiUrl: 'https://aistudio.google.com/app/apikey',
apiName: 'Google AI Studio',
},
grok: {
defaultModel: 'grok-3-latest',
apiUrl: 'https://console.x.ai/',
apiName: 'xAI',
},
kimi: {
defaultModel: 'moonshot-v1-auto',
apiUrl: 'https://platform.moonshot.ai/console/api-keys',
apiName: 'Moonshot',
},
minimax: {
defaultModel: 'MiniMax-M2.7',
apiUrl: 'https://platform.minimax.io',
apiName: 'MiniMax',
},
claw402: { claw402: {
defaultModel: DEFAULT_CLAW402_MODEL, defaultModel: DEFAULT_CLAW402_MODEL,
apiUrl: 'https://claw402.ai', apiUrl: 'https://claw402.ai',

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',
@@ -1087,9 +1092,9 @@ export const translations = {
otherApiEntry: 'Other API Providers', otherApiEntry: 'Other API Providers',
otherApiEntryDesc: otherApiEntryDesc:
'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.', 'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key', payPerCall: 'Pay-as-you-go USDC · All AI Models · No API Key',
recommended: 'Best', recommended: 'Best',
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models', allModelsClaw: 'Pay-as-you-go with USDC — supports all major AI models',
selectAiModel: 'Choose AI Model', selectAiModel: 'Choose AI Model',
allModelsUnified: allModelsUnified:
'All models unified via Claw402. Switch anytime after setup.', 'All models unified via Claw402. Switch anytime after setup.',
@@ -2181,6 +2186,10 @@ export const translations = {
cancel: '取消', cancel: '取消',
positionClosed: '平仓成功', positionClosed: '平仓成功',
closeFailed: '平仓失败', closeFailed: '平仓失败',
closeAll: '一键全平',
confirmCloseAllPositions: '确定要市价平掉全部 {count} 个持仓吗?',
allPositionsClosed: '全部持仓已平',
closeAllPartial: '{count} 个持仓中有 {failed} 个平仓失败',
hideAddress: '隐藏地址', hideAddress: '隐藏地址',
showFullAddress: '显示完整地址', showFullAddress: '显示完整地址',
copyAddress: '复制地址', copyAddress: '复制地址',
@@ -2250,9 +2259,9 @@ export const translations = {
otherApiEntry: '其他 API 模型', otherApiEntry: '其他 API 模型',
otherApiEntryDesc: otherApiEntryDesc:
'如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key再从这里进入。', '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key再从这里进入。',
payPerCall: 'USDC 按付费 · 支持全部 AI 模型 · 无需 API Key', payPerCall: 'USDC 按付费 · 支持全部 AI 模型 · 无需 API Key',
recommended: '推荐', recommended: '推荐',
allModelsClaw: '用 USDC 按付费,支持所有主流 AI 模型', allModelsClaw: '用 USDC 按付费,支持所有主流 AI 模型',
selectAiModel: '① 选择 AI 模型', selectAiModel: '① 选择 AI 模型',
allModelsUnified: '所有模型通过 Claw402 统一调用,创建后可随时切换', allModelsUnified: '所有模型通过 Claw402 统一调用,创建后可随时切换',
setupWallet: '② 设置钱包', setupWallet: '② 设置钱包',
@@ -3293,6 +3302,11 @@ export const translations = {
cancel: 'Batal', cancel: 'Batal',
positionClosed: 'Posisi berhasil ditutup', positionClosed: 'Posisi berhasil ditutup',
closeFailed: 'Gagal menutup posisi', closeFailed: 'Gagal menutup posisi',
closeAll: 'Tutup Semua',
confirmCloseAllPositions:
'Tutup SEMUA {count} posisi terbuka dengan harga pasar?',
allPositionsClosed: 'Semua posisi ditutup',
closeAllPartial: '{failed} dari {count} posisi gagal ditutup',
hideAddress: 'Sembunyikan alamat', hideAddress: 'Sembunyikan alamat',
showFullAddress: 'Tampilkan alamat lengkap', showFullAddress: 'Tampilkan alamat lengkap',
copyAddress: 'Salin alamat', copyAddress: 'Salin alamat',
@@ -3363,7 +3377,7 @@ export const translations = {
otherApiEntry: 'Penyedia API Lain', otherApiEntry: 'Penyedia API Lain',
otherApiEntryDesc: otherApiEntryDesc:
'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.', 'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key', payPerCall: 'Bayar sesuai pemakaian USDC · Semua Model AI · Tanpa API Key',
recommended: 'Terbaik', recommended: 'Terbaik',
allModelsClaw: allModelsClaw:
'Bayar per panggilan dengan USDC — mendukung semua model AI utama', 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',

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>